diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
new file mode 100644
index 0000000000..7f5566fb97
--- /dev/null
+++ b/.devcontainer/Dockerfile
@@ -0,0 +1,5 @@
+FROM node:18-bullseye
+
+RUN useradd -m -s /bin/bash vscode
+RUN mkdir -p /workspaces && chown -R vscode:vscode /workspaces
+WORKDIR /workspaces
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000000..a3bb780550
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,18 @@
+{
+ "dockerComposeFile": "docker-compose.yml",
+ "service": "app",
+ "workspaceFolder": "/workspaces",
+ "customizations": {
+ "vscode": {
+ "extensions": [],
+ "settings": {
+ "terminal.integrated.profiles.linux": {
+ "bash": null
+ }
+ }
+ }
+ },
+ "postCreateCommand": "",
+ "features": { "ghcr.io/devcontainers/features/git:1": {} },
+ "remoteUser": "vscode"
+}
diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
new file mode 100644
index 0000000000..70ebf9b955
--- /dev/null
+++ b/.devcontainer/docker-compose.yml
@@ -0,0 +1,62 @@
+services:
+ app:
+ build:
+ context: ..
+ dockerfile: .devcontainer/Dockerfile
+ # restart: always
+ links:
+ - mongodb
+ - meilisearch
+ # ports:
+ # - 3080:3080 # Change it to 9000:3080 to use nginx
+ extra_hosts: # if you are running APIs on docker you need access to, you will need to uncomment this line and next
+ - "host.docker.internal:host-gateway"
+
+ volumes:
+ # This is where VS Code should expect to find your project's source code and the value of "workspaceFolder" in .devcontainer/devcontainer.json
+ - ..:/workspaces:cached
+ # Uncomment the next line to use Docker from inside the container. See https://aka.ms/vscode-remote/samples/docker-from-docker-compose for details.
+ # - /var/run/docker.sock:/var/run/docker.sock
+ environment:
+ - HOST=0.0.0.0
+ - MONGO_URI=mongodb://mongodb:27017/LibreChat
+ # - OPENAI_REVERSE_PROXY=http://host.docker.internal:8070/v1
+ - MEILI_HOST=http://meilisearch:7700
+
+ # Runs app on the same network as the service container, allows "forwardPorts" in devcontainer.json function.
+ # network_mode: service:another-service
+
+ # Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
+ # (Adding the "ports" property to this file will not forward from a Codespace.)
+
+ # Use a non-root user for all processes - See https://aka.ms/vscode-remote/containers/non-root for details.
+ user: vscode
+
+ # Overrides default command so things don't shut down after the process ends.
+ command: /bin/sh -c "while sleep 1000; do :; done"
+
+ mongodb:
+ container_name: chat-mongodb
+ expose:
+ - 27017
+ # ports:
+ # - 27018:27017
+ image: mongo
+ # restart: always
+ volumes:
+ - ./data-node:/data/db
+ command: mongod --noauth
+ meilisearch:
+ container_name: chat-meilisearch
+ image: getmeili/meilisearch:v1.5
+ # restart: always
+ expose:
+ - 7700
+ # Uncomment this to access meilisearch from outside docker
+ # ports:
+ # - 7700:7700 # if exposing these ports, make sure your master key is not the default value
+ environment:
+ - MEILI_NO_ANALYTICS=true
+ - MEILI_MASTER_KEY=5c71cf56d672d009e36070b5bc5e47b743535ae55c818ae3b735bb6ebfb4ba63
+ volumes:
+ - ./meili_data_v1.5:/meili_data
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000..396f0da3e5
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,17 @@
+**/.circleci
+**/.editorconfig
+**/.dockerignore
+**/.git
+**/.DS_Store
+**/.vscode
+**/node_modules
+
+# Specific patterns to ignore
+data-node
+meili_data*
+librechat*
+Dockerfile*
+docs
+
+# Ignore all hidden files
+.*
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000000..799e44525b
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,782 @@
+#=====================================================================#
+# LibreChat Configuration #
+#=====================================================================#
+# Please refer to the reference documentation for assistance #
+# with configuring your LibreChat environment. #
+# #
+# https://www.librechat.ai/docs/configuration/dotenv #
+#=====================================================================#
+
+#==================================================#
+# Server Configuration #
+#==================================================#
+
+HOST=localhost
+PORT=3080
+
+MONGO_URI=mongodb://127.0.0.1:27017/LibreChat
+#The maximum number of connections in the connection pool. */
+MONGO_MAX_POOL_SIZE=
+#The minimum number of connections in the connection pool. */
+MONGO_MIN_POOL_SIZE=
+#The maximum number of connections that may be in the process of being established concurrently by the connection pool. */
+MONGO_MAX_CONNECTING=
+#The maximum number of milliseconds that a connection can remain idle in the pool before being removed and closed. */
+MONGO_MAX_IDLE_TIME_MS=
+#The maximum time in milliseconds that a thread can wait for a connection to become available. */
+MONGO_WAIT_QUEUE_TIMEOUT_MS=
+# Set to false to disable automatic index creation for all models associated with this connection. */
+MONGO_AUTO_INDEX=
+# Set to `false` to disable Mongoose automatically calling `createCollection()` on every model created on this connection. */
+MONGO_AUTO_CREATE=
+
+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.
+# 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
+
+# Minimum password length for user authentication
+# Default: 8
+# Note: When using LDAP authentication, you may want to set this to 1
+# to bypass local password validation, as LDAP servers handle their own
+# password policies.
+# MIN_PASSWORD_LENGTH=8
+
+#===============#
+# JSON Logging #
+#===============#
+
+# Use when process console logs in cloud deployment like GCP/AWS
+CONSOLE_JSON=false
+
+#===============#
+# Debug Logging #
+#===============#
+
+DEBUG_LOGGING=true
+DEBUG_CONSOLE=false
+
+#=============#
+# Permissions #
+#=============#
+
+# UID=1000
+# GID=1000
+
+#===============#
+# Configuration #
+#===============#
+# Use an absolute path, a relative path, or a URL
+
+# CONFIG_PATH="/alternative/path/to/librechat.yaml"
+
+#===================================================#
+# Endpoints #
+#===================================================#
+
+# ENDPOINTS=openAI,assistants,azureOpenAI,google,anthropic
+
+PROXY=
+
+#===================================#
+# Known Endpoints - librechat.yaml #
+#===================================#
+# https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints
+
+# ANYSCALE_API_KEY=
+# APIPIE_API_KEY=
+# COHERE_API_KEY=
+# DEEPSEEK_API_KEY=
+# DATABRICKS_API_KEY=
+# FIREWORKS_API_KEY=
+# GROQ_API_KEY=
+# HUGGINGFACE_TOKEN=
+# MISTRAL_API_KEY=
+# OPENROUTER_KEY=
+# PERPLEXITY_API_KEY=
+# SHUTTLEAI_API_KEY=
+# TOGETHERAI_API_KEY=
+# UNIFY_API_KEY=
+# XAI_API_KEY=
+
+#============#
+# Anthropic #
+#============#
+
+ANTHROPIC_API_KEY=user_provided
+# ANTHROPIC_MODELS=claude-opus-4-20250514,claude-sonnet-4-20250514,claude-3-7-sonnet-20250219,claude-3-5-sonnet-20241022,claude-3-5-haiku-20241022,claude-3-opus-20240229,claude-3-sonnet-20240229,claude-3-haiku-20240307
+# ANTHROPIC_REVERSE_PROXY=
+
+#============#
+# Azure #
+#============#
+
+# Note: these variables are DEPRECATED
+# Use the `librechat.yaml` configuration for `azureOpenAI` instead
+# You may also continue to use them if you opt out of using the `librechat.yaml` configuration
+
+# AZURE_OPENAI_DEFAULT_MODEL=gpt-3.5-turbo # Deprecated
+# AZURE_OPENAI_MODELS=gpt-3.5-turbo,gpt-4 # Deprecated
+# AZURE_USE_MODEL_AS_DEPLOYMENT_NAME=TRUE # Deprecated
+# AZURE_API_KEY= # Deprecated
+# AZURE_OPENAI_API_INSTANCE_NAME= # Deprecated
+# AZURE_OPENAI_API_DEPLOYMENT_NAME= # Deprecated
+# AZURE_OPENAI_API_VERSION= # Deprecated
+# AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME= # Deprecated
+# AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME= # Deprecated
+
+#=================#
+# AWS Bedrock #
+#=================#
+
+# BEDROCK_AWS_DEFAULT_REGION=us-east-1 # A default region must be provided
+# BEDROCK_AWS_ACCESS_KEY_ID=someAccessKey
+# BEDROCK_AWS_SECRET_ACCESS_KEY=someSecretAccessKey
+# BEDROCK_AWS_SESSION_TOKEN=someSessionToken
+
+# Note: This example list is not meant to be exhaustive. If omitted, all known, supported model IDs will be included for you.
+# BEDROCK_AWS_MODELS=anthropic.claude-3-5-sonnet-20240620-v1:0,meta.llama3-1-8b-instruct-v1:0
+
+# See all Bedrock model IDs here: https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids.html#model-ids-arns
+
+# Notes on specific models:
+# The following models are not support due to not supporting streaming:
+# ai21.j2-mid-v1
+
+# The following models are not support due to not supporting conversation history:
+# ai21.j2-ultra-v1, cohere.command-text-v14, cohere.command-light-text-v14
+
+#============#
+# Google #
+#============#
+
+GOOGLE_KEY=user_provided
+
+# GOOGLE_REVERSE_PROXY=
+# Some reverse proxies do not support the X-goog-api-key header, uncomment to pass the API key in Authorization header instead.
+# GOOGLE_AUTH_HEADER=true
+
+# Gemini API (AI Studio)
+# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash,gemini-2.0-flash-lite
+
+# Vertex AI
+# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash-001,gemini-2.0-flash-lite-001
+
+# GOOGLE_TITLE_MODEL=gemini-2.0-flash-lite-001
+
+# GOOGLE_LOC=us-central1
+
+# Google Safety Settings
+# NOTE: These settings apply to both Vertex AI and Gemini API (AI Studio)
+#
+# For Vertex AI:
+# To use the BLOCK_NONE setting, you need either:
+# (a) Access through an allowlist via your Google account team, or
+# (b) Switch to monthly invoiced billing: https://cloud.google.com/billing/docs/how-to/invoiced-billing
+#
+# For Gemini API (AI Studio):
+# BLOCK_NONE is available by default, no special account requirements.
+#
+# Available options: BLOCK_NONE, BLOCK_ONLY_HIGH, BLOCK_MEDIUM_AND_ABOVE, BLOCK_LOW_AND_ABOVE
+#
+# GOOGLE_SAFETY_SEXUALLY_EXPLICIT=BLOCK_ONLY_HIGH
+# GOOGLE_SAFETY_HATE_SPEECH=BLOCK_ONLY_HIGH
+# GOOGLE_SAFETY_HARASSMENT=BLOCK_ONLY_HIGH
+# GOOGLE_SAFETY_DANGEROUS_CONTENT=BLOCK_ONLY_HIGH
+# GOOGLE_SAFETY_CIVIC_INTEGRITY=BLOCK_ONLY_HIGH
+
+#============#
+# OpenAI #
+#============#
+
+OPENAI_API_KEY=user_provided
+# OPENAI_MODELS=gpt-5,gpt-5-codex,gpt-5-mini,gpt-5-nano,o3-pro,o3,o4-mini,gpt-4.1,gpt-4.1-mini,gpt-4.1-nano,o3-mini,o1-pro,o1,gpt-4o,gpt-4o-mini
+
+DEBUG_OPENAI=false
+
+# TITLE_CONVO=false
+# OPENAI_TITLE_MODEL=gpt-4o-mini
+
+# OPENAI_SUMMARIZE=true
+# OPENAI_SUMMARY_MODEL=gpt-4o-mini
+
+# OPENAI_FORCE_PROMPT=true
+
+# OPENAI_REVERSE_PROXY=
+
+# OPENAI_ORGANIZATION=
+
+#====================#
+# Assistants API #
+#====================#
+
+ASSISTANTS_API_KEY=user_provided
+# ASSISTANTS_BASE_URL=
+# ASSISTANTS_MODELS=gpt-4o,gpt-4o-mini,gpt-3.5-turbo-0125,gpt-3.5-turbo-16k-0613,gpt-3.5-turbo-16k,gpt-3.5-turbo,gpt-4,gpt-4-0314,gpt-4-32k-0314,gpt-4-0613,gpt-3.5-turbo-0613,gpt-3.5-turbo-1106,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview
+
+#==========================#
+# Azure Assistants API #
+#==========================#
+
+# Note: You should map your credentials with custom variables according to your Azure OpenAI Configuration
+# The models for Azure Assistants are also determined by your Azure OpenAI configuration.
+
+# More info, including how to enable use of Assistants with Azure here:
+# https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints/azure#using-assistants-with-azure
+
+CREDS_KEY=f34be427ebb29de8d88c107a71546019685ed8b241d8f2ed00c3df97ad2566f0
+CREDS_IV=e2341419ec3dd3d19b13a1a87fafcbfb
+
+# Azure AI Search
+#-----------------
+AZURE_AI_SEARCH_SERVICE_ENDPOINT=
+AZURE_AI_SEARCH_INDEX_NAME=
+AZURE_AI_SEARCH_API_KEY=
+
+AZURE_AI_SEARCH_API_VERSION=
+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_API_KEY= # Create or reuse OpenAI API key for image generation tool
+# IMAGE_GEN_OAI_BASEURL= # Custom OpenAI base URL for image generation tool
+# IMAGE_GEN_OAI_AZURE_API_VERSION= # Custom Azure OpenAI deployments
+# IMAGE_GEN_OAI_DESCRIPTION=
+# 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=
+# DALLE3_API_KEY=
+# DALLE2_API_KEY=
+# DALLE3_SYSTEM_PROMPT=
+# DALLE2_SYSTEM_PROMPT=
+# DALLE_REVERSE_PROXY=
+# DALLE3_BASEURL=
+# DALLE2_BASEURL=
+
+# DALL·E (via Azure OpenAI)
+# Note: requires some of the variables above to be set
+#----------------
+# DALLE3_AZURE_API_VERSION=
+# DALLE2_AZURE_API_VERSION=
+
+# Flux
+#-----------------
+FLUX_API_BASE_URL=https://api.us1.bfl.ai
+# FLUX_API_BASE_URL = 'https://api.bfl.ml';
+
+# Get your API key at https://api.us1.bfl.ai/auth/profile
+# FLUX_API_KEY=
+
+# Google
+#-----------------
+GOOGLE_SEARCH_API_KEY=
+GOOGLE_CSE_ID=
+
+# YOUTUBE
+#-----------------
+YOUTUBE_API_KEY=
+
+# Stable Diffusion
+#-----------------
+SD_WEBUI_URL=http://host.docker.internal:7860
+
+# Tavily
+#-----------------
+TAVILY_API_KEY=
+
+# Traversaal
+#-----------------
+TRAVERSAAL_API_KEY=
+
+# WolframAlpha
+#-----------------
+WOLFRAM_APP_ID=
+
+# Zapier
+#-----------------
+ZAPIER_NLA_API_KEY=
+
+#==================================================#
+# Search #
+#==================================================#
+
+SEARCH=true
+MEILI_NO_ANALYTICS=true
+MEILI_HOST=http://0.0.0.0:7700
+MEILI_MASTER_KEY=DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFCt
+
+# Optional: Disable indexing, useful in a multi-node setup
+# where only one instance should perform an index sync.
+# MEILI_NO_SYNC=true
+
+#==================================================#
+# Speech to Text & Text to Speech #
+#==================================================#
+
+STT_API_KEY=
+TTS_API_KEY=
+
+#==================================================#
+# RAG #
+#==================================================#
+# More info: https://www.librechat.ai/docs/configuration/rag_api
+
+# RAG_OPENAI_BASEURL=
+# RAG_OPENAI_API_KEY=
+# RAG_USE_FULL_CONTEXT=
+# EMBEDDINGS_PROVIDER=openai
+# EMBEDDINGS_MODEL=text-embedding-3-small
+
+#===================================================#
+# User System #
+#===================================================#
+
+#========================#
+# Moderation #
+#========================#
+
+OPENAI_MODERATION=false
+OPENAI_MODERATION_API_KEY=
+# OPENAI_MODERATION_REVERSE_PROXY=
+
+BAN_VIOLATIONS=true
+BAN_DURATION=1000 * 60 * 60 * 2
+BAN_INTERVAL=20
+
+LOGIN_VIOLATION_SCORE=1
+REGISTRATION_VIOLATION_SCORE=1
+CONCURRENT_VIOLATION_SCORE=1
+MESSAGE_VIOLATION_SCORE=1
+NON_BROWSER_VIOLATION_SCORE=20
+TTS_VIOLATION_SCORE=0
+STT_VIOLATION_SCORE=0
+FORK_VIOLATION_SCORE=0
+IMPORT_VIOLATION_SCORE=0
+FILE_UPLOAD_VIOLATION_SCORE=0
+
+LOGIN_MAX=7
+LOGIN_WINDOW=5
+REGISTER_MAX=5
+REGISTER_WINDOW=60
+
+LIMIT_CONCURRENT_MESSAGES=true
+CONCURRENT_MESSAGE_MAX=2
+
+LIMIT_MESSAGE_IP=true
+MESSAGE_IP_MAX=40
+MESSAGE_IP_WINDOW=1
+
+LIMIT_MESSAGE_USER=false
+MESSAGE_USER_MAX=40
+MESSAGE_USER_WINDOW=1
+
+ILLEGAL_MODEL_REQ_SCORE=5
+
+#========================#
+# Balance #
+#========================#
+
+# CHECK_BALANCE=false
+# START_BALANCE=20000 # note: the number of tokens that will be credited after registration.
+
+#========================#
+# Registration and Login #
+#========================#
+
+ALLOW_EMAIL_LOGIN=true
+ALLOW_REGISTRATION=true
+ALLOW_SOCIAL_LOGIN=false
+ALLOW_SOCIAL_REGISTRATION=false
+ALLOW_PASSWORD_RESET=false
+# ALLOW_ACCOUNT_DELETION=true # note: enabled by default if omitted/commented out
+ALLOW_UNVERIFIED_EMAIL_LOGIN=true
+
+SESSION_EXPIRY=1000 * 60 * 15
+REFRESH_TOKEN_EXPIRY=(1000 * 60 * 60 * 24) * 7
+
+JWT_SECRET=16f8c0ef4a5d391b26034086c628469d3f9f497f08163ab9b40137092f2909ef
+JWT_REFRESH_SECRET=eaa5191f2914e30b9387fd84e254e4ba6fc51b4654968a9b0803b456a54b8418
+
+# Discord
+DISCORD_CLIENT_ID=
+DISCORD_CLIENT_SECRET=
+DISCORD_CALLBACK_URL=/oauth/discord/callback
+
+# Facebook
+FACEBOOK_CLIENT_ID=
+FACEBOOK_CLIENT_SECRET=
+FACEBOOK_CALLBACK_URL=/oauth/facebook/callback
+
+# GitHub
+GITHUB_CLIENT_ID=
+GITHUB_CLIENT_SECRET=
+GITHUB_CALLBACK_URL=/oauth/github/callback
+# GitHub Enterprise
+# GITHUB_ENTERPRISE_BASE_URL=
+# GITHUB_ENTERPRISE_USER_AGENT=
+
+# Google
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
+GOOGLE_CALLBACK_URL=/oauth/google/callback
+
+# Apple
+APPLE_CLIENT_ID=
+APPLE_TEAM_ID=
+APPLE_KEY_ID=
+APPLE_PRIVATE_KEY_PATH=
+APPLE_CALLBACK_URL=/oauth/apple/callback
+
+# OpenID
+OPENID_CLIENT_ID=
+OPENID_CLIENT_SECRET=
+OPENID_ISSUER=
+OPENID_SESSION_SECRET=
+OPENID_SCOPE="openid profile email"
+OPENID_CALLBACK_URL=/oauth/openid/callback
+OPENID_REQUIRED_ROLE=
+OPENID_REQUIRED_ROLE_TOKEN_KIND=
+OPENID_REQUIRED_ROLE_PARAMETER_PATH=
+OPENID_ADMIN_ROLE=
+OPENID_ADMIN_ROLE_PARAMETER_PATH=
+OPENID_ADMIN_ROLE_TOKEN_KIND=
+# Set to determine which user info property returned from OpenID Provider to store as the User's username
+OPENID_USERNAME_CLAIM=
+# Set to determine which user info property returned from OpenID Provider to store as the User's name
+OPENID_NAME_CLAIM=
+# Optional audience parameter for OpenID authorization requests
+OPENID_AUDIENCE=
+
+OPENID_BUTTON_LABEL=
+OPENID_IMAGE_URL=
+# Set to true to automatically redirect to the OpenID provider when a user visits the login page
+# This will bypass the login form completely for users, only use this if OpenID is your only authentication method
+OPENID_AUTO_REDIRECT=false
+# Set to true to use PKCE (Proof Key for Code Exchange) for OpenID authentication
+OPENID_USE_PKCE=false
+#Set to true to reuse openid tokens for authentication management instead of using the mongodb session and the custom refresh token.
+OPENID_REUSE_TOKENS=
+#By default, signing key verification results are cached in order to prevent excessive HTTP requests to the JWKS endpoint.
+#If a signing key matching the kid is found, this will be cached and the next time this kid is requested the signing key will be served from the cache.
+#Default is true.
+OPENID_JWKS_URL_CACHE_ENABLED=
+OPENID_JWKS_URL_CACHE_TIME= # 600000 ms eq to 10 minutes leave empty to disable caching
+#Set to true to trigger token exchange flow to acquire access token for the userinfo endpoint.
+OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED=
+OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE="user.read" # example for Scope Needed for Microsoft Graph API
+# Set to true to use the OpenID Connect end session endpoint for logout
+OPENID_USE_END_SESSION_ENDPOINT=
+
+#========================#
+# SharePoint Integration #
+#========================#
+# Requires Entra ID (OpenID) authentication to be configured
+
+# Enable SharePoint file picker in chat and agent panels
+# ENABLE_SHAREPOINT_FILEPICKER=true
+
+# SharePoint tenant base URL (e.g., https://yourtenant.sharepoint.com)
+# SHAREPOINT_BASE_URL=https://yourtenant.sharepoint.com
+
+# Microsoft Graph API And SharePoint scopes for file picker
+# SHAREPOINT_PICKER_SHAREPOINT_SCOPE==https://yourtenant.sharepoint.com/AllSites.Read
+# SHAREPOINT_PICKER_GRAPH_SCOPE=Files.Read.All
+#========================#
+
+# SAML
+# Note: If OpenID is enabled, SAML authentication will be automatically disabled.
+SAML_ENTRY_POINT=
+SAML_ISSUER=
+SAML_CERT=
+SAML_CALLBACK_URL=/oauth/saml/callback
+SAML_SESSION_SECRET=
+
+# Attribute mappings (optional)
+SAML_EMAIL_CLAIM=
+SAML_USERNAME_CLAIM=
+SAML_GIVEN_NAME_CLAIM=
+SAML_FAMILY_NAME_CLAIM=
+SAML_PICTURE_CLAIM=
+SAML_NAME_CLAIM=
+
+# Logint buttion settings (optional)
+SAML_BUTTON_LABEL=
+SAML_IMAGE_URL=
+
+# Whether the SAML Response should be signed.
+# - If "true", the entire `SAML Response` will be signed.
+# - If "false" or unset, only the `SAML Assertion` will be signed (default behavior).
+# SAML_USE_AUTHN_RESPONSE_SIGNED=
+
+
+#===============================================#
+# Microsoft Graph API / Entra ID Integration #
+#===============================================#
+
+# Enable Entra ID people search integration in permissions/sharing system
+# When enabled, the people picker will search both local database and Entra ID
+USE_ENTRA_ID_FOR_PEOPLE_SEARCH=false
+
+# When enabled, entra id groups owners will be considered as members of the group
+ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS=false
+
+# Microsoft Graph API scopes needed for people/group search
+# Default scopes provide access to user profiles and group memberships
+OPENID_GRAPH_SCOPES=User.Read,People.Read,GroupMember.Read.All
+
+# LDAP
+LDAP_URL=
+LDAP_BIND_DN=
+LDAP_BIND_CREDENTIALS=
+LDAP_USER_SEARCH_BASE=
+#LDAP_SEARCH_FILTER="mail="
+LDAP_CA_CERT_PATH=
+# LDAP_TLS_REJECT_UNAUTHORIZED=
+# LDAP_STARTTLS=
+# LDAP_LOGIN_USES_USERNAME=true
+# LDAP_ID=
+# LDAP_USERNAME=
+# LDAP_EMAIL=
+# LDAP_FULL_NAME=
+
+#========================#
+# Email Password Reset #
+#========================#
+
+EMAIL_SERVICE=
+EMAIL_HOST=
+EMAIL_PORT=25
+EMAIL_ENCRYPTION=
+EMAIL_ENCRYPTION_HOSTNAME=
+EMAIL_ALLOW_SELFSIGNED=
+EMAIL_USERNAME=
+EMAIL_PASSWORD=
+EMAIL_FROM_NAME=
+EMAIL_FROM=noreply@librechat.ai
+
+#========================#
+# Mailgun API #
+#========================#
+
+# MAILGUN_API_KEY=your-mailgun-api-key
+# MAILGUN_DOMAIN=mg.yourdomain.com
+# EMAIL_FROM=noreply@yourdomain.com
+# EMAIL_FROM_NAME="LibreChat"
+
+# # Optional: For EU region
+# MAILGUN_HOST=https://api.eu.mailgun.net
+
+#========================#
+# Firebase CDN #
+#========================#
+
+FIREBASE_API_KEY=
+FIREBASE_AUTH_DOMAIN=
+FIREBASE_PROJECT_ID=
+FIREBASE_STORAGE_BUCKET=
+FIREBASE_MESSAGING_SENDER_ID=
+FIREBASE_APP_ID=
+
+#========================#
+# S3 AWS Bucket #
+#========================#
+
+AWS_ENDPOINT_URL=
+AWS_ACCESS_KEY_ID=
+AWS_SECRET_ACCESS_KEY=
+AWS_REGION=
+AWS_BUCKET_NAME=
+
+#========================#
+# Azure Blob Storage #
+#========================#
+
+AZURE_STORAGE_CONNECTION_STRING=
+AZURE_STORAGE_PUBLIC_ACCESS=false
+AZURE_CONTAINER_NAME=files
+
+#========================#
+# Shared Links #
+#========================#
+
+ALLOW_SHARED_LINKS=true
+ALLOW_SHARED_LINKS_PUBLIC=true
+
+#==============================#
+# Static File Cache Control #
+#==============================#
+
+# Leave commented out to use defaults: 1 day (86400 seconds) for s-maxage and 2 days (172800 seconds) for max-age
+# NODE_ENV must be set to production for these to take effect
+# STATIC_CACHE_MAX_AGE=172800
+# STATIC_CACHE_S_MAX_AGE=86400
+
+# If you have another service in front of your LibreChat doing compression, disable express based compression here
+# DISABLE_COMPRESSION=true
+
+# If you have gzipped version of uploaded image images in the same folder, this will enable gzip scan and serving of these images
+# Note: The images folder will be scanned on startup and a ma kept in memory. Be careful for large number of images.
+# ENABLE_IMAGE_OUTPUT_GZIP_SCAN=true
+
+#===================================================#
+# UI #
+#===================================================#
+
+APP_TITLE=LibreChat
+# CUSTOM_FOOTER="My custom footer"
+HELP_AND_FAQ_URL=https://librechat.ai
+
+# SHOW_BIRTHDAY_ICON=true
+
+# Google tag manager id
+#ANALYTICS_GTM_ID=user provided google tag manager id
+
+# limit conversation file imports to a certain number of bytes in size to avoid the container
+# maxing out memory limitations by unremarking this line and supplying a file size in bytes
+# such as the below example of 250 mib
+# CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES=262144000
+
+
+#===============#
+# REDIS Options #
+#===============#
+
+# Enable Redis for caching and session storage
+# USE_REDIS=true
+
+# Single Redis instance
+# REDIS_URI=redis://127.0.0.1:6379
+
+# Redis cluster (multiple nodes)
+# REDIS_URI=redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
+
+# Redis with TLS/SSL encryption and CA certificate
+# REDIS_URI=rediss://127.0.0.1:6380
+# REDIS_CA=/path/to/ca-cert.pem
+
+# Elasticache may need to use an alternate dnsLookup for TLS connections. see "Special Note: Aws Elasticache Clusters with TLS" on this webpage: https://www.npmjs.com/package/ioredis
+# Enable alternative dnsLookup for redis
+# REDIS_USE_ALTERNATIVE_DNS_LOOKUP=true
+
+# Redis authentication (if required)
+# REDIS_USERNAME=your_redis_username
+# REDIS_PASSWORD=your_redis_password
+
+# Redis key prefix configuration
+# Use environment variable name for dynamic prefix (recommended for cloud deployments)
+# REDIS_KEY_PREFIX_VAR=K_REVISION
+# Or use static prefix directly
+# REDIS_KEY_PREFIX=librechat
+
+# Redis connection limits
+# REDIS_MAX_LISTENERS=40
+
+# Redis ping interval in seconds (0 = disabled, >0 = enabled)
+# When set to a positive integer, Redis clients will ping the server at this interval to keep connections alive
+# When unset or 0, no pinging is performed (recommended for most use cases)
+# REDIS_PING_INTERVAL=300
+
+# Force specific cache namespaces to use in-memory storage even when Redis is enabled
+# Comma-separated list of CacheKeys (e.g., ROLES,MESSAGES)
+# FORCED_IN_MEMORY_CACHE_NAMESPACES=ROLES,MESSAGES
+
+# Leader Election Configuration (for multi-instance deployments with Redis)
+# Duration in seconds that the leader lease is valid before it expires (default: 25)
+# LEADER_LEASE_DURATION=25
+# Interval in seconds at which the leader renews its lease (default: 10)
+# LEADER_RENEW_INTERVAL=10
+# Maximum number of retry attempts when renewing the lease fails (default: 3)
+# LEADER_RENEW_ATTEMPTS=3
+# Delay in seconds between retry attempts when renewing the lease (default: 0.5)
+# LEADER_RENEW_RETRY_DELAY=0.5
+
+#==================================================#
+# Others #
+#==================================================#
+# You should leave the following commented out #
+
+# NODE_ENV=
+
+# E2E_USER_EMAIL=
+# E2E_USER_PASSWORD=
+
+#=====================================================#
+# Cache Headers #
+#=====================================================#
+# Headers that control caching of the index.html #
+# Default configuration prevents caching to ensure #
+# users always get the latest version. Customize #
+# only if you understand caching implications. #
+
+# INDEX_CACHE_CONTROL=no-cache, no-store, must-revalidate
+# INDEX_PRAGMA=no-cache
+# INDEX_EXPIRES=0
+
+# no-cache: Forces validation with server before using cached version
+# no-store: Prevents storing the response entirely
+# must-revalidate: Prevents using stale content when offline
+
+#=====================================================#
+# OpenWeather #
+#=====================================================#
+OPENWEATHER_API_KEY=
+
+#====================================#
+# LibreChat Code Interpreter API #
+#====================================#
+
+# https://code.librechat.ai
+# LIBRECHAT_CODE_API_KEY=your-key
+
+#======================#
+# Web Search #
+#======================#
+
+# Note: All of the following variable names can be customized.
+# Omit values to allow user to provide them.
+
+# For more information on configuration values, see:
+# https://librechat.ai/docs/features/web_search
+
+# Search Provider (Required)
+# SERPER_API_KEY=your_serper_api_key
+
+# Scraper (Required)
+# FIRECRAWL_API_KEY=your_firecrawl_api_key
+# Optional: Custom Firecrawl API URL
+# FIRECRAWL_API_URL=your_firecrawl_api_url
+
+# Reranker (Required)
+# JINA_API_KEY=your_jina_api_key
+# or
+# COHERE_API_KEY=your_cohere_api_key
+
+#======================#
+# MCP Configuration #
+#======================#
+
+# Treat 401/403 responses as OAuth requirement when no oauth metadata found
+# MCP_OAUTH_ON_AUTH_ERROR=true
+
+# Timeout for OAuth detection requests in milliseconds
+# MCP_OAUTH_DETECTION_TIMEOUT=5000
+
+# Cache connection status checks for this many milliseconds to avoid expensive verification
+# MCP_CONNECTION_CHECK_TTL=60000
+
+# Skip code challenge method validation (e.g., for AWS Cognito that supports S256 but doesn't advertise it)
+# When set to true, forces S256 code challenge even if not advertised in .well-known/openid-configuration
+# MCP_SKIP_CODE_CHALLENGE_CHECK=false
diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000000..cb767cbd7c
--- /dev/null
+++ b/.github/CODE_OF_CONDUCT.md
@@ -0,0 +1,132 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, religion, or sexual identity
+and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the
+ overall community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or
+ advances of any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email
+ address, without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement here on GitHub or
+on the official [Discord Server](https://discord.librechat.ai).
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series
+of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or
+permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within
+the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.0, available at
+https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
+
+Community Impact Guidelines were inspired by [Mozilla's code of conduct
+enforcement ladder](https://github.com/mozilla/diversity).
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see the FAQ at
+https://www.contributor-covenant.org/faq. Translations are available at
+https://www.contributor-covenant.org/translations.
+
+---
+
+## [Go Back to ReadMe](../README.md)
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
new file mode 100644
index 0000000000..ad0a75ab9b
--- /dev/null
+++ b/.github/CONTRIBUTING.md
@@ -0,0 +1,168 @@
+# Contributor Guidelines
+
+Thank you to all the contributors who have helped make this project possible! We welcome various types of contributions, such as bug reports, documentation improvements, feature requests, and code contributions.
+
+## Contributing Guidelines
+
+If the feature you would like to contribute has not already received prior approval from the project maintainers (i.e., the feature is currently on the [roadmap](https://github.com/users/danny-avila/projects/2)), please submit a request in the [Feature Requests & Suggestions category](https://github.com/danny-avila/LibreChat/discussions/new?category=feature-requests-suggestions) of the discussions board before beginning work on it. The requests should include specific implementation details, including areas of the application that will be affected by the change (including designs if applicable), and any other relevant information that might be required for a speedy review. However, proposals are not required for small changes, bug fixes, or documentation improvements. Small changes and bug fixes should be tied to an [issue](https://github.com/danny-avila/LibreChat/issues) and included in the corresponding pull request for tracking purposes.
+
+Please note that a pull request involving a feature that has not been reviewed and approved by the project maintainers may be rejected. We appreciate your understanding and cooperation.
+
+If you would like to discuss the changes you wish to make, join our [Discord community](https://discord.librechat.ai), where you can engage with other contributors and seek guidance from the community.
+
+## Our Standards
+
+We strive to maintain a positive and inclusive environment within our project community. We expect all contributors to adhere to the following standards:
+
+- Using welcoming and inclusive language.
+- Being respectful of differing viewpoints and experiences.
+- Gracefully accepting constructive criticism.
+- Focusing on what is best for the community.
+- Showing empathy towards other community members.
+
+Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that do not align with these standards.
+
+## To contribute to this project, please adhere to the following guidelines:
+
+## 1. Development Setup
+
+1. Use Node.JS 20.x.
+2. Install typescript globally: `npm i -g typescript`.
+3. Run `npm ci` to install dependencies.
+4. Build the data provider: `npm run build:data-provider`.
+5. Build data schemas: `npm run build:data-schemas`.
+6. Build API methods: `npm run build:api`.
+7. Setup and run unit tests:
+ - 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, 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`.
+
+## 3. Git Workflow
+
+We utilize a GitFlow workflow to manage changes to this project's codebase. Follow these general steps when contributing code:
+
+1. Fork the repository and create a new branch with a descriptive slash-based name (e.g., `new/feature/x`).
+2. Implement your changes and ensure that all tests pass.
+3. Commit your changes using conventional commit messages with GitFlow flags. Begin the commit message with a tag indicating the change type, such as "feat" (new feature), "fix" (bug fix), "docs" (documentation), or "refactor" (code refactoring), followed by a brief summary of the changes (e.g., `feat: Add new feature X to the project`).
+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.
+
+## 4. Commit Message Format
+
+We follow the [semantic format](https://gist.github.com/joshbuchea/6f47e86d2510bce28f8e7f42ae84c716) for commit messages.
+
+### Example
+
+```
+feat: add hat wobble
+^--^ ^------------^
+| |
+| +-> Summary in present tense.
+|
++-------> Type: chore, docs, feat, fix, refactor, style, or test.
+```
+
+### Commit Guidelines
+- Do your best to reduce the number of commits, organizing them as much possible. Look into [squashing commits](https://www.freecodecamp.org/news/git-squash-commits/) in order to keep a neat history.
+- For those that care about maximizing commits for stats, adhere to the above as I 'squash and merge' an unorganized and/or unformatted commit history, which reduces the number of your commits to 1,:
+```
+* Update Br.tsx
+
+* Update Es.tsx
+
+* Update Br.tsx
+```
+
+
+## 5. Pull Request Process
+
+When submitting a pull request, please follow these guidelines:
+
+- Ensure that any installation or build dependencies are removed before the end of the layer when doing a build.
+- Update the README.md with details of changes to the interface, including new environment variables, exposed ports, useful file locations, and container parameters.
+- Increase the version numbers in any example files and the README.md to reflect the new version that the pull request represents. We use [SemVer](http://semver.org/) for versioning.
+
+Ensure that your changes meet the following criteria:
+
+- All tests pass as highlighted [above](#1-development-notes).
+- The code is well-formatted and adheres to our coding standards.
+- 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.
+
+## 6. Naming Conventions
+
+Apply the following naming conventions to branches, labels, and other Git-related entities:
+
+- **Branch names:** Descriptive and slash-based (e.g., `new/feature/x`).
+- **Labels:** Descriptive and kebab case (e.g., `bug-fix`).
+- **JS/TS:** Directories and file names: Descriptive and camelCase. First letter uppercased for React files (e.g., `helperFunction.ts, ReactComponent.tsx`).
+- **Docs:** Directories and file names: Descriptive and snake_case (e.g., `config_files.md`).
+
+## 7. TypeScript Conversion
+
+1. **Original State**: The project was initially developed entirely in JavaScript (JS).
+
+2. **Frontend Transition**:
+ - We are in the process of transitioning the frontend from JS to TypeScript (TS).
+ - The transition is nearing completion.
+ - This conversion is feasible due to React's capability to intermix JS and TS prior to code compilation. It's standard practice to compile/bundle the code in such scenarios.
+
+3. **Backend Considerations**:
+ - Transitioning the backend to TypeScript would be a more intricate process, especially for an established Express.js server.
+
+ - **Options for Transition**:
+ - **Single Phase Overhaul**: This involves converting the entire backend to TypeScript in one go. It's the most straightforward approach but can be disruptive, especially for larger codebases.
+
+ - **Incremental Transition**: Convert parts of the backend progressively. This can be done by:
+ - Maintaining a separate directory for TypeScript files.
+ - Gradually migrating and testing individual modules or routes.
+ - Using a build tool like `tsc` to compile TypeScript files independently until the entire transition is complete.
+
+ - **Compilation Considerations**:
+ - Introducing a compilation step for the server is an option. This would involve using tools like `ts-node` for development and `tsc` for production builds.
+ - However, this is not a conventional approach for Express.js servers and could introduce added complexity, especially in terms of build and deployment processes.
+
+ - **Current Stance**: At present, this backend transition is of lower priority and might not be pursued.
+
+## 8. Module Import Conventions
+
+- `npm` packages first,
+ - from longest line (top) to shortest (bottom)
+
+- Followed by typescript types (pertains to data-provider and client workspaces)
+ - longest line (top) to shortest (bottom)
+ - types from package come first
+
+- Lastly, local imports
+ - longest line (top) to shortest (bottom)
+ - imports with alias `~` treated the same as relative import with respect to line length
+
+**Note:** ESLint will automatically enforce these import conventions when you run `npm run lint --fix` or through pre-commit hooks.
+
+---
+
+Please ensure that you adapt this summary to fit the specific context and nuances of your project.
+
+---
+
+## [Go Back to ReadMe](../README.md)
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000000..37ef799acb
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,13 @@
+# These are supported funding model platforms
+
+github: [danny-avila]
+patreon: # Replace with a single Patreon username
+open_collective: # Replace with a single Open Collective username
+ko_fi: # Replace with a single Ko-fi username
+tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
+community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
+liberapay: # Replace with a single Liberapay username
+issuehunt: # Replace with a single IssueHunt username
+otechie: # Replace with a single Otechie username
+lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
+custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml
new file mode 100644
index 0000000000..610396959f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml
@@ -0,0 +1,96 @@
+name: Bug Report
+description: File a bug report
+title: "[Bug]: "
+labels: ["🐛 bug"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to fill out this bug report!
+
+ Before submitting, please:
+ - Search existing [Issues and Discussions](https://github.com/danny-avila/LibreChat/discussions) to see if your bug has already been reported
+ - Use [Discussions](https://github.com/danny-avila/LibreChat/discussions) instead of Issues for:
+ - General inquiries
+ - Help with setup
+ - Questions about whether you're experiencing a bug
+ - type: textarea
+ id: what-happened
+ attributes:
+ label: What happened?
+ description: Also tell us, what did you expect to happen?
+ placeholder: Please give as many details as possible
+ validations:
+ required: true
+ - type: textarea
+ id: version-info
+ attributes:
+ label: Version Information
+ description: |
+ If using Docker, please run and provide the output of:
+ ```bash
+ docker images | grep librechat
+ ```
+
+ If running from source, please run and provide the output of:
+ ```bash
+ git rev-parse HEAD
+ ```
+ placeholder: Paste the output here
+ validations:
+ required: true
+ - type: textarea
+ id: steps-to-reproduce
+ attributes:
+ label: Steps to Reproduce
+ description: Please list the steps needed to reproduce the issue.
+ placeholder: "1. Step 1\n2. Step 2\n3. Step 3"
+ validations:
+ required: true
+ - type: dropdown
+ id: browsers
+ attributes:
+ label: What browsers are you seeing the problem on?
+ multiple: true
+ options:
+ - Firefox
+ - Chrome
+ - Safari
+ - Microsoft Edge
+ - Mobile (iOS)
+ - Mobile (Android)
+ - type: textarea
+ id: logs
+ attributes:
+ label: Relevant log output
+ description: |
+ Please paste relevant logs that were created when reproducing the error.
+
+ Log locations:
+ - Docker: Project root directory ./logs
+ - npm: ./api/logs
+
+ There are two types of logs that can help diagnose the issue:
+ - debug logs (debug-YYYY-MM-DD.log)
+ - error logs (error-YYYY-MM-DD.log)
+
+ Error logs contain exact stack traces and are especially helpful, but both can provide valuable information.
+ Please only include the relevant portions of logs that correspond to when you reproduced the error.
+
+ 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:
+ label: Screenshots
+ description: If applicable, add screenshots to help explain your problem. You can drag and drop, paste images directly here or link to them.
+ - type: checkboxes
+ id: terms
+ attributes:
+ label: Code of Conduct
+ description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/danny-avila/LibreChat/blob/main/.github/CODE_OF_CONDUCT.md)
+ options:
+ - label: I agree to follow this project's Code of Conduct
+ required: true
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml
new file mode 100644
index 0000000000..613c9e0a01
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml
@@ -0,0 +1,49 @@
+name: Feature Request
+description: File a feature request
+title: "[Enhancement]: "
+labels: ["✨ enhancement"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thank you for taking the time to fill this out!
+ - type: textarea
+ id: what
+ attributes:
+ label: What features would you like to see added?
+ description: Please provide as many details as possible.
+ placeholder: Please provide as many details as possible.
+ validations:
+ required: true
+ - type: textarea
+ id: details
+ attributes:
+ label: More details
+ description: Please provide additional details if needed.
+ placeholder: Please provide additional details if needed.
+ validations:
+ required: true
+ - type: dropdown
+ id: subject
+ attributes:
+ label: Which components are impacted by your request?
+ multiple: true
+ options:
+ - General
+ - UI
+ - Endpoints
+ - Plugins
+ - Other
+ - type: textarea
+ id: screenshots
+ attributes:
+ label: Pictures
+ description: If relevant, please include images to help clarify your request. You can drag and drop images directly here, paste them, or provide a link to them.
+ - type: checkboxes
+ id: terms
+ attributes:
+ label: Code of Conduct
+ description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/danny-avila/LibreChat/blob/main/.github/CODE_OF_CONDUCT.md)
+ options:
+ - label: I agree to follow this project's Code of Conduct
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/LOCIZE_TRANSLATION_ACCESS_REQUEST.yml b/.github/ISSUE_TEMPLATE/LOCIZE_TRANSLATION_ACCESS_REQUEST.yml
new file mode 100644
index 0000000000..49b01a814d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/LOCIZE_TRANSLATION_ACCESS_REQUEST.yml
@@ -0,0 +1,42 @@
+name: Locize Translation Access Request
+description: Request access to an additional language in Locize for LibreChat translations.
+title: "Locize Access Request: "
+labels: ["🌍 i18n", "🔑 access request"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thank you for your interest in contributing to LibreChat translations!
+ Please fill out the form below to request access to an additional language in **Locize**.
+
+ **🔗 Available Languages:** [View the list here](https://www.librechat.ai/docs/translation)
+
+ **📌 Note:** Ensure that the requested language is supported before submitting your request.
+ - type: input
+ id: account_name
+ attributes:
+ label: Locize Account Name
+ description: Please provide your Locize account name (e.g., John Doe).
+ placeholder: e.g., John Doe
+ validations:
+ required: true
+ - type: input
+ id: language_requested
+ attributes:
+ label: Language Code (ISO 639-1)
+ description: |
+ Enter the **ISO 639-1** language code for the language you want to translate into.
+ Example: `es` for Spanish, `zh-Hant` for Traditional Chinese.
+
+ **🔗 Reference:** [Available Languages](https://www.librechat.ai/docs/translation)
+ placeholder: e.g., es
+ validations:
+ required: true
+ - type: checkboxes
+ id: agreement
+ attributes:
+ label: Agreement
+ description: By submitting this request, you confirm that you will contribute responsibly and adhere to the project guidelines.
+ options:
+ - label: I agree to use my access solely for contributing to LibreChat translations.
+ required: true
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/NEW-LANGUAGE-REQUEST.yml b/.github/ISSUE_TEMPLATE/NEW-LANGUAGE-REQUEST.yml
new file mode 100644
index 0000000000..5fddced9f8
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/NEW-LANGUAGE-REQUEST.yml
@@ -0,0 +1,33 @@
+name: New Language Request
+description: Request to add a new language for LibreChat translations.
+title: "New Language Request: "
+labels: ["✨ enhancement", "🌍 i18n"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thank you for taking the time to submit a new language request! Please fill out the following details so we can review your request.
+ - type: input
+ id: language_name
+ attributes:
+ label: Language Name
+ description: Please provide the full name of the language (e.g., Spanish, Mandarin).
+ placeholder: e.g., Spanish
+ validations:
+ required: true
+ - type: input
+ id: iso_code
+ attributes:
+ label: ISO 639-1 Code
+ description: Please provide the ISO 639-1 code for the language (e.g., es for Spanish). You can refer to [this list](https://www.w3schools.com/tags/ref_language_codes.asp) for valid codes.
+ placeholder: e.g., es
+ validations:
+ required: true
+ - type: checkboxes
+ id: terms
+ attributes:
+ label: Code of Conduct
+ description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/danny-avila/LibreChat/blob/main/.github/CODE_OF_CONDUCT.md).
+ options:
+ - label: I agree to follow this project's Code of Conduct
+ required: true
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
new file mode 100644
index 0000000000..b01e04e016
--- /dev/null
+++ b/.github/SECURITY.md
@@ -0,0 +1,63 @@
+# Security Policy
+
+At LibreChat, we prioritize the security of our project and value the contributions of security researchers in helping us improve the security of our codebase. If you discover a security vulnerability within our project, we appreciate your responsible disclosure. Please follow the guidelines below to report any vulnerabilities to us:
+
+**Note: Only report sensitive vulnerability details via the appropriate private communication channels mentioned below. Public channels, such as GitHub issues and Discord, should be used for initiating contact and establishing private communication channels.**
+
+## Communication Channels
+
+When reporting a security vulnerability, you have the following options to reach out to us:
+
+- **Option 1: GitHub Security Advisory System**: We encourage you to use GitHub's Security Advisory system to report any security vulnerabilities you find. This allows us to receive vulnerability reports directly through GitHub. For more information on how to submit a security advisory report, please refer to the [GitHub Security Advisories documentation](https://docs.github.com/en/code-security/getting-started-with-security-vulnerability-alerts/about-github-security-advisories).
+
+- **Option 2: GitHub Issues**: You can initiate first contact via GitHub Issues. However, please note that initial contact through GitHub Issues should not include any sensitive details.
+
+- **Option 3: Discord Server**: You can join our [Discord community](https://discord.librechat.ai) and initiate first contact in the `#issues` channel. However, please ensure that initial contact through Discord does not include any sensitive details.
+
+_After the initial contact, we will establish a private communication channel for further discussion._
+
+### When submitting a vulnerability report, please provide us with the following information:
+
+- A clear description of the vulnerability, including steps to reproduce it.
+- The version(s) of the project affected by the vulnerability.
+- Any additional information that may be useful for understanding and addressing the issue.
+
+We strive to acknowledge vulnerability reports within 72 hours and will keep you informed of the progress towards resolution.
+
+## Security Updates and Patching
+
+We are committed to maintaining the security of our open-source project, LibreChat, and promptly addressing any identified vulnerabilities. To ensure the security of our project, we adhere to the following practices:
+
+- We prioritize security updates for the current major release of our software.
+- We actively monitor the GitHub Security Advisory system and the `#issues` channel on Discord for any vulnerability reports.
+- We promptly review and validate reported vulnerabilities and take appropriate actions to address them.
+- We release security patches and updates in a timely manner to mitigate any identified vulnerabilities.
+
+Please note that as a security-conscious community, we may not always disclose detailed information about security issues until we have determined that doing so would not put our users or the project at risk. We appreciate your understanding and cooperation in these matters.
+
+## Scope
+
+This security policy applies to the following GitHub repository:
+
+- Repository: [LibreChat](https://github.librechat.ai)
+
+## Contact
+
+If you have any questions or concerns regarding the security of our project, please join our [Discord community](https://discord.librechat.ai) and report them in the appropriate channel. You can also reach out to us by [opening an issue](https://github.com/danny-avila/LibreChat/issues/new) on GitHub. Please note that the response time may vary depending on the nature and severity of the inquiry.
+
+## Acknowledgments
+
+We would like to express our gratitude to the security researchers and community members who help us improve the security of our project. Your contributions are invaluable, and we sincerely appreciate your efforts.
+
+## Bug Bounty Program
+
+We currently do not have a bug bounty program in place. However, we welcome and appreciate any
+
+ security-related contributions through pull requests (PRs) that address vulnerabilities in our codebase. We believe in the power of collaboration to improve the security of our project and invite you to join us in making it more robust.
+
+**Reference**
+- https://cheatsheetseries.owasp.org/cheatsheets/Vulnerability_Disclosure_Cheat_Sheet.html
+
+---
+
+## [Go Back to ReadMe](../README.md)
diff --git a/.github/configuration-release.json b/.github/configuration-release.json
new file mode 100644
index 0000000000..68fe80ed8f
--- /dev/null
+++ b/.github/configuration-release.json
@@ -0,0 +1,60 @@
+{
+ "categories": [
+ {
+ "title": "### ✨ New Features",
+ "labels": ["feat"]
+ },
+ {
+ "title": "### 🌍 Internationalization",
+ "labels": ["i18n"]
+ },
+ {
+ "title": "### 👐 Accessibility",
+ "labels": ["a11y"]
+ },
+ {
+ "title": "### 🔧 Fixes",
+ "labels": ["Fix", "fix"]
+ },
+ {
+ "title": "### ⚙️ Other Changes",
+ "labels": ["ci", "style", "docs", "refactor", "chore"]
+ }
+ ],
+ "ignore_labels": [
+ "🔁 duplicate",
+ "📊 analytics",
+ "🌱 good first issue",
+ "🔍 investigation",
+ "🙏 help wanted",
+ "❌ invalid",
+ "❓ question",
+ "🚫 wontfix",
+ "🚀 release",
+ "version"
+ ],
+ "base_branches": ["main"],
+ "sort": {
+ "order": "ASC",
+ "on_property": "mergedAt"
+ },
+ "label_extractor": [
+ {
+ "pattern": "^(?:[^A-Za-z0-9]*)(feat|fix|chore|docs|refactor|ci|style|a11y|i18n)\\s*:",
+ "target": "$1",
+ "flags": "i",
+ "on_property": "title",
+ "method": "match"
+ },
+ {
+ "pattern": "^(?:[^A-Za-z0-9]*)(v\\d+\\.\\d+\\.\\d+(?:-rc\\d+)?).*",
+ "target": "version",
+ "flags": "i",
+ "on_property": "title",
+ "method": "match"
+ }
+ ],
+ "template": "## [#{{TO_TAG}}] - #{{TO_TAG_DATE}}\n\nChanges from #{{FROM_TAG}} to #{{TO_TAG}}.\n\n#{{CHANGELOG}}\n\n[See full release details][release-#{{TO_TAG}}]\n\n[release-#{{TO_TAG}}]: https://github.com/#{{OWNER}}/#{{REPO}}/releases/tag/#{{TO_TAG}}\n\n---",
+ "pr_template": "- #{{TITLE}} by **@#{{AUTHOR}}** in [##{{NUMBER}}](#{{URL}})",
+ "empty_template": "- no changes"
+}
\ No newline at end of file
diff --git a/.github/configuration-unreleased.json b/.github/configuration-unreleased.json
new file mode 100644
index 0000000000..29eaf5e13b
--- /dev/null
+++ b/.github/configuration-unreleased.json
@@ -0,0 +1,68 @@
+{
+ "categories": [
+ {
+ "title": "### ✨ New Features",
+ "labels": ["feat"]
+ },
+ {
+ "title": "### 🌍 Internationalization",
+ "labels": ["i18n"]
+ },
+ {
+ "title": "### 👐 Accessibility",
+ "labels": ["a11y"]
+ },
+ {
+ "title": "### 🔧 Fixes",
+ "labels": ["Fix", "fix"]
+ },
+ {
+ "title": "### ⚙️ Other Changes",
+ "labels": ["ci", "style", "docs", "refactor", "chore"]
+ }
+ ],
+ "ignore_labels": [
+ "🔁 duplicate",
+ "📊 analytics",
+ "🌱 good first issue",
+ "🔍 investigation",
+ "🙏 help wanted",
+ "❌ invalid",
+ "❓ question",
+ "🚫 wontfix",
+ "🚀 release",
+ "version",
+ "action"
+ ],
+ "base_branches": ["main"],
+ "sort": {
+ "order": "ASC",
+ "on_property": "mergedAt"
+ },
+ "label_extractor": [
+ {
+ "pattern": "^(?:[^A-Za-z0-9]*)(feat|fix|chore|docs|refactor|ci|style|a11y|i18n)\\s*:",
+ "target": "$1",
+ "flags": "i",
+ "on_property": "title",
+ "method": "match"
+ },
+ {
+ "pattern": "^(?:[^A-Za-z0-9]*)(v\\d+\\.\\d+\\.\\d+(?:-rc\\d+)?).*",
+ "target": "version",
+ "flags": "i",
+ "on_property": "title",
+ "method": "match"
+ },
+ {
+ "pattern": "^(?:[^A-Za-z0-9]*)(action)\\b.*",
+ "target": "action",
+ "flags": "i",
+ "on_property": "title",
+ "method": "match"
+ }
+ ],
+ "template": "## [Unreleased]\n\n#{{CHANGELOG}}\n\n---",
+ "pr_template": "- #{{TITLE}} by **@#{{AUTHOR}}** in [##{{NUMBER}}](#{{URL}})",
+ "empty_template": "- no changes"
+}
\ No newline at end of file
diff --git a/.github/playwright.yml b/.github/playwright.yml
new file mode 100644
index 0000000000..28eca14d58
--- /dev/null
+++ b/.github/playwright.yml
@@ -0,0 +1,72 @@
+# name: Playwright Tests
+# on:
+# pull_request:
+# branches:
+# - main
+# - dev
+# - release/*
+# paths:
+# - 'api/**'
+# - 'client/**'
+# - 'packages/**'
+# - 'e2e/**'
+# jobs:
+# tests_e2e:
+# name: Run Playwright tests
+# if: github.event.pull_request.head.repo.full_name == 'danny-avila/LibreChat'
+# timeout-minutes: 60
+# runs-on: ubuntu-latest
+# env:
+# NODE_ENV: CI
+# CI: true
+# SEARCH: false
+# BINGAI_TOKEN: user_provided
+# CHATGPT_TOKEN: user_provided
+# MONGO_URI: ${{ secrets.MONGO_URI }}
+# OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+# E2E_USER_EMAIL: ${{ secrets.E2E_USER_EMAIL }}
+# E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }}
+# JWT_SECRET: ${{ secrets.JWT_SECRET }}
+# JWT_REFRESH_SECRET: ${{ secrets.JWT_REFRESH_SECRET }}
+# CREDS_KEY: ${{ secrets.CREDS_KEY }}
+# CREDS_IV: ${{ secrets.CREDS_IV }}
+# DOMAIN_CLIENT: ${{ secrets.DOMAIN_CLIENT }}
+# DOMAIN_SERVER: ${{ secrets.DOMAIN_SERVER }}
+# PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 # Skip downloading during npm install
+# PLAYWRIGHT_BROWSERS_PATH: 0 # Places binaries to node_modules/@playwright/test
+# TITLE_CONVO: false
+# steps:
+# - uses: actions/checkout@v4
+# - uses: actions/setup-node@v4
+# with:
+# node-version: 18
+# cache: 'npm'
+
+# - name: Install global dependencies
+# run: npm ci
+
+# # - name: Remove sharp dependency
+# # run: rm -rf node_modules/sharp
+
+# # - name: Install sharp with linux dependencies
+# # run: cd api && SHARP_IGNORE_GLOBAL_LIBVIPS=1 npm install --arch=x64 --platform=linux --libc=glibc sharp
+
+# - name: Build Client
+# run: npm run frontend
+
+# - name: Install Playwright
+# run: |
+# npx playwright install-deps
+# npm install -D @playwright/test@latest
+# npx playwright install chromium
+
+# - name: Run Playwright tests
+# run: npm run e2e:ci
+
+# - name: Upload playwright report
+# uses: actions/upload-artifact@v3
+# if: always()
+# with:
+# name: playwright-report
+# path: e2e/playwright-report/
+# retention-days: 30
\ No newline at end of file
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000000..cb637787f1
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,41 @@
+# Pull Request Template
+
+⚠️ Before Submitting a PR, Please Review:
+- Please ensure that you have thoroughly read and understood the [Contributing Docs](https://github.com/danny-avila/LibreChat/blob/main/.github/CONTRIBUTING.md) before submitting your Pull Request.
+
+⚠️ Documentation Updates Notice:
+- Kindly note that documentation updates are managed in this repository: [librechat.ai](https://github.com/LibreChat-AI/librechat.ai)
+
+## Summary
+
+Please provide a brief summary of your changes and the related issue. Include any motivation and context that is relevant to your changes. If there are any dependencies necessary for your changes, please list them here.
+
+## Change Type
+
+Please delete any irrelevant options.
+
+- [ ] Bug fix (non-breaking change which fixes an issue)
+- [ ] New feature (non-breaking change which adds functionality)
+- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
+- [ ] This change requires a documentation update
+- [ ] Translation update
+
+## Testing
+
+Please describe your test process and include instructions so that we can reproduce your test. If there are any important variables for your testing configuration, list them here.
+
+### **Test Configuration**:
+
+## Checklist
+
+Please delete any irrelevant options.
+
+- [ ] My code adheres to this project's style guidelines
+- [ ] I have performed a self-review of my own code
+- [ ] I have commented in any complex areas of my code
+- [ ] I have made pertinent documentation changes
+- [ ] My changes do not introduce new warnings
+- [ ] I have written tests demonstrating that my changes are effective or that my feature works
+- [ ] Local unit tests pass with my changes
+- [ ] Any changes dependent on mine have been merged and published in downstream modules.
+- [ ] A pull request for updating the documentation has been submitted.
diff --git a/.github/workflows/a11y.yml b/.github/workflows/a11y.yml
new file mode 100644
index 0000000000..a7cfd08169
--- /dev/null
+++ b/.github/workflows/a11y.yml
@@ -0,0 +1,26 @@
+name: Lint for accessibility issues
+
+on:
+ pull_request:
+ paths:
+ - 'client/src/**'
+ workflow_dispatch:
+ inputs:
+ run_workflow:
+ description: 'Set to true to run this workflow'
+ required: true
+ default: 'false'
+
+jobs:
+ axe-linter:
+ runs-on: ubuntu-latest
+ if: >
+ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'danny-avila/LibreChat') ||
+ (github.event_name == 'workflow_dispatch' && github.event.inputs.run_workflow == 'true')
+
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dequelabs/axe-linter-action@v1
+ with:
+ api_key: ${{ secrets.AXE_LINTER_API_KEY }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/backend-review.yml b/.github/workflows/backend-review.yml
new file mode 100644
index 0000000000..8375f398c3
--- /dev/null
+++ b/.github/workflows/backend-review.yml
@@ -0,0 +1,75 @@
+name: Backend Unit Tests
+on:
+ pull_request:
+ branches:
+ - main
+ - dev
+ - dev-staging
+ - release/*
+ paths:
+ - 'api/**'
+ - 'packages/**'
+jobs:
+ tests_Backend:
+ name: Run Backend unit tests
+ timeout-minutes: 60
+ runs-on: ubuntu-latest
+ env:
+ MONGO_URI: ${{ secrets.MONGO_URI }}
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+ JWT_SECRET: ${{ secrets.JWT_SECRET }}
+ CREDS_KEY: ${{ secrets.CREDS_KEY }}
+ CREDS_IV: ${{ secrets.CREDS_IV }}
+ BAN_VIOLATIONS: ${{ secrets.BAN_VIOLATIONS }}
+ BAN_DURATION: ${{ secrets.BAN_DURATION }}
+ BAN_INTERVAL: ${{ secrets.BAN_INTERVAL }}
+ NODE_ENV: CI
+ steps:
+ - uses: actions/checkout@v4
+ - name: Use Node.js 20.x
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Install Data Provider Package
+ run: npm run build:data-provider
+
+ - name: Install Data Schemas Package
+ run: npm run build:data-schemas
+
+ - name: Install API Package
+ run: npm run build:api
+
+ - name: Create empty auth.json file
+ run: |
+ mkdir -p api/data
+ echo '{}' > api/data/auth.json
+
+ - name: Check for Circular dependency in rollup
+ working-directory: ./packages/data-provider
+ run: |
+ output=$(npm run rollup:api)
+ echo "$output"
+ if echo "$output" | grep -q "Circular dependency"; then
+ echo "Error: Circular dependency detected!"
+ exit 1
+ fi
+
+ - name: Prepare .env.test file
+ run: cp api/test/.env.test.example api/test/.env.test
+
+ - name: Run unit tests
+ run: cd api && npm run test:ci
+
+ - name: Run librechat-data-provider unit tests
+ run: cd packages/data-provider && npm run test:ci
+
+ - name: Run @librechat/data-schemas unit tests
+ run: cd packages/data-schemas && npm run test:ci
+
+ - name: Run @librechat/api unit tests
+ run: cd packages/api && npm run test:ci
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000000..a2131c4b98
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,38 @@
+name: Linux_Container_Workflow
+
+on:
+ workflow_dispatch:
+
+env:
+ RUNNER_VERSION: 2.293.0
+
+jobs:
+ build-and-push:
+ runs-on: ubuntu-latest
+ steps:
+ # checkout the repo
+ - name: 'Checkout GitHub Action'
+ uses: actions/checkout@main
+
+ - name: 'Login via Azure CLI'
+ uses: azure/login@v1
+ with:
+ creds: ${{ secrets.AZURE_CREDENTIALS }}
+
+ - name: 'Build GitHub Runner container image'
+ uses: azure/docker-login@v1
+ with:
+ login-server: ${{ secrets.REGISTRY_LOGIN_SERVER }}
+ username: ${{ secrets.REGISTRY_USERNAME }}
+ password: ${{ secrets.REGISTRY_PASSWORD }}
+ - run: |
+ docker build --build-arg RUNNER_VERSION=${{ env.RUNNER_VERSION }} -t ${{ secrets.REGISTRY_LOGIN_SERVER }}/pwd9000-github-runner-lin:${{ env.RUNNER_VERSION }} .
+
+ - name: 'Push container image to ACR'
+ uses: azure/docker-login@v1
+ with:
+ login-server: ${{ secrets.REGISTRY_LOGIN_SERVER }}
+ username: ${{ secrets.REGISTRY_USERNAME }}
+ password: ${{ secrets.REGISTRY_PASSWORD }}
+ - run: |
+ docker push ${{ secrets.REGISTRY_LOGIN_SERVER }}/pwd9000-github-runner-lin:${{ env.RUNNER_VERSION }}
diff --git a/.github/workflows/cache-integration-tests.yml b/.github/workflows/cache-integration-tests.yml
new file mode 100644
index 0000000000..251b61564a
--- /dev/null
+++ b/.github/workflows/cache-integration-tests.yml
@@ -0,0 +1,90 @@
+name: Cache Integration Tests
+
+on:
+ pull_request:
+ branches:
+ - main
+ - dev
+ - dev-staging
+ - release/*
+ paths:
+ - 'packages/api/src/cache/**'
+ - 'packages/api/src/cluster/**'
+ - 'packages/api/src/mcp/**'
+ - 'redis-config/**'
+ - '.github/workflows/cache-integration-tests.yml'
+
+jobs:
+ cache_integration_tests:
+ name: Integration Tests that use actual Redis Cache
+ timeout-minutes: 30
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Use Node.js 20.x
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: 'npm'
+
+ - name: Install Redis tools
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y redis-server redis-tools
+
+ - name: Start Single Redis Instance
+ run: |
+ redis-server --daemonize yes --port 6379
+ sleep 2
+ # Verify single Redis is running
+ redis-cli -p 6379 ping || exit 1
+
+ - name: Start Redis Cluster
+ working-directory: redis-config
+ run: |
+ chmod +x start-cluster.sh stop-cluster.sh
+ ./start-cluster.sh
+ sleep 10
+ # Verify cluster is running
+ redis-cli -p 7001 cluster info || exit 1
+ redis-cli -p 7002 cluster info || exit 1
+ redis-cli -p 7003 cluster info || exit 1
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build packages
+ run: |
+ npm run build:data-provider
+ npm run build:data-schemas
+ npm run build:api
+
+ - name: Run all cache integration tests (Single Redis Node)
+ working-directory: packages/api
+ env:
+ NODE_ENV: test
+ USE_REDIS: true
+ USE_REDIS_CLUSTER: false
+ REDIS_URI: redis://127.0.0.1:6379
+ run: npm run test:cache-integration
+
+ - name: Run all cache integration tests (Redis Cluster)
+ working-directory: packages/api
+ env:
+ NODE_ENV: test
+ USE_REDIS: true
+ USE_REDIS_CLUSTER: true
+ REDIS_URI: redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
+ run: npm run test:cache-integration
+
+ - name: Stop Redis Cluster
+ if: always()
+ working-directory: redis-config
+ run: ./stop-cluster.sh || true
+
+ - name: Stop Single Redis Instance
+ if: always()
+ run: redis-cli -p 6379 shutdown || true
diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml
new file mode 100644
index 0000000000..e3e3e445e4
--- /dev/null
+++ b/.github/workflows/client.yml
@@ -0,0 +1,64 @@
+name: Publish `@librechat/client` to NPM
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - 'packages/client/package.json'
+ workflow_dispatch:
+ inputs:
+ reason:
+ description: 'Reason for manual trigger'
+ required: false
+ default: 'Manual publish requested'
+
+permissions:
+ id-token: write # Required for OIDC trusted publishing
+ contents: read
+
+jobs:
+ build-and-publish:
+ runs-on: ubuntu-latest
+ environment: publish # Must match npm trusted publisher config
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20.x'
+ registry-url: 'https://registry.npmjs.org'
+
+ - name: Update npm for OIDC support
+ run: npm install -g npm@latest # Must be 11.5.1+ for provenance
+
+ - name: Install client dependencies
+ run: cd packages/client && npm ci
+
+ - name: Build client
+ run: cd packages/client && npm run build
+
+ - name: Check version change
+ id: check
+ working-directory: packages/client
+ run: |
+ PACKAGE_VERSION=$(node -p "require('./package.json').version")
+ PUBLISHED_VERSION=$(npm view @librechat/client version 2>/dev/null || echo "0.0.0")
+ if [ "$PACKAGE_VERSION" = "$PUBLISHED_VERSION" ]; then
+ echo "No version change, skipping publish"
+ echo "skip=true" >> $GITHUB_OUTPUT
+ else
+ echo "Version changed, proceeding with publish"
+ echo "skip=false" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Pack package
+ if: steps.check.outputs.skip != 'true'
+ working-directory: packages/client
+ run: npm pack
+
+ - name: Publish
+ if: steps.check.outputs.skip != 'true'
+ working-directory: packages/client
+ run: npm publish *.tgz --access public --provenance
diff --git a/.github/workflows/data-provider.yml b/.github/workflows/data-provider.yml
new file mode 100644
index 0000000000..9a514b0076
--- /dev/null
+++ b/.github/workflows/data-provider.yml
@@ -0,0 +1,47 @@
+name: Publish `librechat-data-provider` to NPM
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - 'packages/data-provider/package.json'
+ workflow_dispatch:
+ inputs:
+ reason:
+ description: 'Reason for manual trigger'
+ required: false
+ default: 'Manual publish requested'
+
+permissions:
+ id-token: write # Required for OIDC trusted publishing
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ - run: cd packages/data-provider && npm ci
+ - run: cd packages/data-provider && npm run build
+
+ publish-npm:
+ needs: build
+ runs-on: ubuntu-latest
+ environment: publish # Must match npm trusted publisher config
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ registry-url: 'https://registry.npmjs.org'
+
+ - name: Update npm for OIDC support
+ run: npm install -g npm@latest # Must be 11.5.1+ for provenance
+
+ - run: cd packages/data-provider && npm ci
+ - run: cd packages/data-provider && npm run build
+ - run: cd packages/data-provider && npm publish --provenance
diff --git a/.github/workflows/data-schemas.yml b/.github/workflows/data-schemas.yml
new file mode 100644
index 0000000000..882dc4f4b6
--- /dev/null
+++ b/.github/workflows/data-schemas.yml
@@ -0,0 +1,64 @@
+name: Publish `@librechat/data-schemas` to NPM
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - 'packages/data-schemas/package.json'
+ workflow_dispatch:
+ inputs:
+ reason:
+ description: 'Reason for manual trigger'
+ required: false
+ default: 'Manual publish requested'
+
+permissions:
+ id-token: write # Required for OIDC trusted publishing
+ contents: read
+
+jobs:
+ build-and-publish:
+ runs-on: ubuntu-latest
+ environment: publish # Must match npm trusted publisher config
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20.x'
+ registry-url: 'https://registry.npmjs.org'
+
+ - name: Update npm for OIDC support
+ run: npm install -g npm@latest # Must be 11.5.1+ for provenance
+
+ - name: Install dependencies
+ run: cd packages/data-schemas && npm ci
+
+ - name: Build
+ run: cd packages/data-schemas && npm run build
+
+ - name: Check version change
+ id: check
+ working-directory: packages/data-schemas
+ run: |
+ PACKAGE_VERSION=$(node -p "require('./package.json').version")
+ PUBLISHED_VERSION=$(npm view @librechat/data-schemas version 2>/dev/null || echo "0.0.0")
+ if [ "$PACKAGE_VERSION" = "$PUBLISHED_VERSION" ]; then
+ echo "No version change, skipping publish"
+ echo "skip=true" >> $GITHUB_OUTPUT
+ else
+ echo "Version changed, proceeding with publish"
+ echo "skip=false" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Pack package
+ if: steps.check.outputs.skip != 'true'
+ working-directory: packages/data-schemas
+ run: npm pack
+
+ - name: Publish
+ if: steps.check.outputs.skip != 'true'
+ working-directory: packages/data-schemas
+ run: npm publish *.tgz --access public --provenance
diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml
new file mode 100644
index 0000000000..a255932e3e
--- /dev/null
+++ b/.github/workflows/deploy-dev.yml
@@ -0,0 +1,46 @@
+name: Update Test Server
+
+on:
+ workflow_run:
+ workflows: ["Docker Dev Branch Images Build"]
+ types:
+ - completed
+ workflow_dispatch:
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ if: |
+ github.repository == 'danny-avila/LibreChat' &&
+ (github.event_name == 'workflow_dispatch' ||
+ (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'dev'))
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Install SSH Key
+ uses: shimataro/ssh-key-action@v2
+ with:
+ key: ${{ secrets.DO_SSH_PRIVATE_KEY }}
+ known_hosts: ${{ secrets.DO_KNOWN_HOSTS }}
+
+ - name: Run update script on DigitalOcean Droplet
+ env:
+ DO_HOST: ${{ secrets.DO_HOST }}
+ DO_USER: ${{ secrets.DO_USER }}
+ run: |
+ ssh -o StrictHostKeyChecking=no ${DO_USER}@${DO_HOST} << EOF
+ sudo -i -u danny bash << 'EEOF'
+ cd ~/LibreChat && \
+ git fetch origin main && \
+ sudo npm run stop:deployed && \
+ sudo docker images --format "{{.Repository}}:{{.ID}}" | grep -E "lc-dev|librechat" | cut -d: -f2 | xargs -r sudo docker rmi -f || true && \
+ sudo npm run update:deployed && \
+ git checkout dev && \
+ git pull origin dev && \
+ git checkout do-deploy && \
+ git rebase dev && \
+ sudo npm run start:deployed && \
+ echo "Update completed. Application should be running now."
+ EEOF
+ EOF
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000000..5c143b4531
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,38 @@
+name: Deploy_GHRunner_Linux_ACI
+
+on:
+ workflow_dispatch:
+
+env:
+ RUNNER_VERSION: 2.293.0
+ ACI_RESOURCE_GROUP: 'Demo-ACI-GitHub-Runners-RG'
+ ACI_NAME: 'gh-runner-linux-01'
+ DNS_NAME_LABEL: 'gh-lin-01'
+ GH_OWNER: ${{ github.repository_owner }}
+ GH_REPOSITORY: 'LibreChat' #Change here to deploy self hosted runner ACI to another repo.
+
+jobs:
+ deploy-gh-runner-aci:
+ runs-on: ubuntu-latest
+ steps:
+ # checkout the repo
+ - name: 'Checkout GitHub Action'
+ uses: actions/checkout@v4
+
+ - name: 'Login via Azure CLI'
+ uses: azure/login@v1
+ with:
+ creds: ${{ secrets.AZURE_CREDENTIALS }}
+
+ - name: 'Deploy to Azure Container Instances'
+ uses: 'azure/aci-deploy@v1'
+ with:
+ resource-group: ${{ env.ACI_RESOURCE_GROUP }}
+ image: ${{ secrets.REGISTRY_LOGIN_SERVER }}/pwd9000-github-runner-lin:${{ env.RUNNER_VERSION }}
+ registry-login-server: ${{ secrets.REGISTRY_LOGIN_SERVER }}
+ registry-username: ${{ secrets.REGISTRY_USERNAME }}
+ registry-password: ${{ secrets.REGISTRY_PASSWORD }}
+ name: ${{ env.ACI_NAME }}
+ dns-name-label: ${{ env.DNS_NAME_LABEL }}
+ environment-variables: GH_TOKEN=${{ secrets.PAT_TOKEN }} GH_OWNER=${{ env.GH_OWNER }} GH_REPOSITORY=${{ env.GH_REPOSITORY }}
+ location: 'eastus'
diff --git a/.github/workflows/dev-branch-images.yml b/.github/workflows/dev-branch-images.yml
new file mode 100644
index 0000000000..b7ad470314
--- /dev/null
+++ b/.github/workflows/dev-branch-images.yml
@@ -0,0 +1,72 @@
+name: Docker Dev Branch Images Build
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - dev
+ paths:
+ - 'api/**'
+ - 'client/**'
+ - 'packages/**'
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ include:
+ - target: api-build
+ file: Dockerfile.multi
+ image_name: lc-dev-api
+ - target: node
+ file: Dockerfile
+ image_name: lc-dev
+
+ steps:
+ # Check out the repository
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ # Set up QEMU
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ # Set up Docker Buildx
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ # Log in to GitHub Container Registry
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v2
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # Login to Docker Hub
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ # Prepare the environment
+ - name: Prepare environment
+ run: |
+ cp .env.example .env
+
+ # Build and push Docker images for each target
+ - name: Build and push Docker images
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ${{ matrix.file }}
+ push: true
+ tags: |
+ ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }}
+ ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.sha }}
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
+ platforms: linux/amd64,linux/arm64
+ target: ${{ matrix.target }}
\ No newline at end of file
diff --git a/.github/workflows/dev-images.yml b/.github/workflows/dev-images.yml
new file mode 100644
index 0000000000..41d427c6c8
--- /dev/null
+++ b/.github/workflows/dev-images.yml
@@ -0,0 +1,72 @@
+name: Docker Dev Images Build
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+ paths:
+ - 'api/**'
+ - 'client/**'
+ - 'packages/**'
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ include:
+ - target: api-build
+ file: Dockerfile.multi
+ image_name: librechat-dev-api
+ - target: node
+ file: Dockerfile
+ image_name: librechat-dev
+
+ steps:
+ # Check out the repository
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ # Set up QEMU
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ # Set up Docker Buildx
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ # Log in to GitHub Container Registry
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v2
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # Login to Docker Hub
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ # Prepare the environment
+ - name: Prepare environment
+ run: |
+ cp .env.example .env
+
+ # Build and push Docker images for each target
+ - name: Build and push Docker images
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ${{ matrix.file }}
+ push: true
+ tags: |
+ ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }}
+ ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.sha }}
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
+ platforms: linux/amd64,linux/arm64
+ target: ${{ matrix.target }}
diff --git a/.github/workflows/dev-staging-images.yml b/.github/workflows/dev-staging-images.yml
new file mode 100644
index 0000000000..e63dc5f0af
--- /dev/null
+++ b/.github/workflows/dev-staging-images.yml
@@ -0,0 +1,66 @@
+name: Docker Dev Staging Images Build
+
+on:
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ include:
+ - target: api-build
+ file: Dockerfile.multi
+ image_name: lc-dev-staging-api
+ - target: node
+ file: Dockerfile
+ image_name: lc-dev-staging
+
+ steps:
+ # Check out the repository
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ # Set up QEMU
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ # Set up Docker Buildx
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ # Log in to GitHub Container Registry
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v2
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # Login to Docker Hub
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ # Prepare the environment
+ - name: Prepare environment
+ run: |
+ cp .env.example .env
+
+ # Build and push Docker images for each target
+ - name: Build and push Docker images
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ${{ matrix.file }}
+ push: true
+ tags: |
+ ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }}
+ ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.sha }}
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
+ platforms: linux/amd64,linux/arm64
+ target: ${{ matrix.target }}
+
diff --git a/.github/workflows/eslint-ci.yml b/.github/workflows/eslint-ci.yml
new file mode 100644
index 0000000000..8203da4e8b
--- /dev/null
+++ b/.github/workflows/eslint-ci.yml
@@ -0,0 +1,60 @@
+name: ESLint Code Quality Checks
+
+on:
+ pull_request:
+ branches:
+ - main
+ - dev
+ - dev-staging
+ - release/*
+ paths:
+ - 'api/**'
+ - 'client/**'
+
+jobs:
+ eslint_checks:
+ name: Run ESLint Linting
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ security-events: write
+ actions: read
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Node.js 20.x
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ # Run ESLint on changed files within the api/ and client/ directories.
+ - name: Run ESLint on changed files
+ run: |
+ # Extract the base commit SHA from the pull_request event payload.
+ BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
+ echo "Base commit SHA: $BASE_SHA"
+
+ # Get changed files (only JS/TS files in api/ or client/)
+ CHANGED_FILES=$(git diff --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD | grep -E '^(api|client)/.*\.(js|jsx|ts|tsx)$' || true)
+
+ # Debug output
+ echo "Changed files:"
+ echo "$CHANGED_FILES"
+
+ # Ensure there are files to lint before running ESLint
+ if [[ -z "$CHANGED_FILES" ]]; then
+ echo "No matching files changed. Skipping ESLint."
+ exit 0
+ fi
+
+ # Run ESLint
+ npx eslint --no-error-on-unmatched-pattern \
+ --config eslint.config.mjs \
+ $CHANGED_FILES
diff --git a/.github/workflows/frontend-review.yml b/.github/workflows/frontend-review.yml
new file mode 100644
index 0000000000..907d07d307
--- /dev/null
+++ b/.github/workflows/frontend-review.yml
@@ -0,0 +1,57 @@
+name: Frontend Unit Tests
+
+on:
+ pull_request:
+ branches:
+ - main
+ - dev
+ - dev-staging
+ - release/*
+ paths:
+ - 'client/**'
+ - 'packages/data-provider/**'
+
+jobs:
+ tests_frontend_ubuntu:
+ name: Run frontend unit tests on Ubuntu
+ timeout-minutes: 60
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Use Node.js 20.x
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build Client
+ run: npm run frontend:ci
+
+ - name: Run unit tests
+ run: npm run test:ci --verbose
+ working-directory: client
+
+ tests_frontend_windows:
+ name: Run frontend unit tests on Windows
+ timeout-minutes: 60
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Use Node.js 20.x
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build Client
+ run: npm run frontend:ci
+
+ - name: Run unit tests
+ run: npm run test:ci --verbose
+ working-directory: client
\ No newline at end of file
diff --git a/.github/workflows/generate_embeddings.yml b/.github/workflows/generate_embeddings.yml
new file mode 100644
index 0000000000..c514f9c1d6
--- /dev/null
+++ b/.github/workflows/generate_embeddings.yml
@@ -0,0 +1,20 @@
+name: 'generate_embeddings'
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+ paths:
+ - 'docs/**'
+
+jobs:
+ generate:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: supabase/embeddings-generator@v0.0.5
+ with:
+ supabase-url: ${{ secrets.SUPABASE_URL }}
+ supabase-service-role-key: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
+ openai-key: ${{ secrets.OPENAI_DOC_EMBEDDINGS_KEY }}
+ docs-root-path: 'docs'
\ No newline at end of file
diff --git a/.github/workflows/helmcharts.yml b/.github/workflows/helmcharts.yml
new file mode 100644
index 0000000000..2b9f7f45de
--- /dev/null
+++ b/.github/workflows/helmcharts.yml
@@ -0,0 +1,75 @@
+name: Build Helm Charts on Tag
+
+# The workflow is triggered when a tag is pushed
+on:
+ push:
+ tags:
+ - "chart-*"
+
+jobs:
+ release:
+ permissions:
+ contents: write
+ packages: write
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Configure Git
+ run: |
+ git config user.name "$GITHUB_ACTOR"
+ git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
+
+ - name: Install Helm
+ uses: azure/setup-helm@v4
+ env:
+ GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
+
+ - name: Build Subchart Deps
+ run: |
+ cd helm/librechat
+ helm dependency build
+ cd ../librechat-rag-api
+ helm dependency build
+
+ - name: Get Chart Version
+ id: chart-version
+ run: |
+ CHART_VERSION=$(echo "${{ github.ref_name }}" | cut -d'-' -f2)
+ echo "CHART_VERSION=${CHART_VERSION}" >> "$GITHUB_OUTPUT"
+
+ # Log in to GitHub Container Registry
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # Run Helm OCI Charts Releaser
+ # This is for the librechat chart
+ - name: Release Helm OCI Charts for librechat
+ uses: appany/helm-oci-chart-releaser@v0.4.2
+ with:
+ name: librechat
+ repository: ${{ github.actor }}/librechat-chart
+ tag: ${{ steps.chart-version.outputs.CHART_VERSION }}
+ path: helm/librechat
+ registry: ghcr.io
+ registry_username: ${{ github.actor }}
+ registry_password: ${{ secrets.GITHUB_TOKEN }}
+
+ # this is for the librechat-rag-api chart
+ - name: Release Helm OCI Charts for librechat-rag-api
+ uses: appany/helm-oci-chart-releaser@v0.4.2
+ with:
+ name: librechat-rag-api
+ repository: ${{ github.actor }}/librechat-chart
+ tag: ${{ steps.chart-version.outputs.CHART_VERSION }}
+ path: helm/librechat-rag-api
+ registry: ghcr.io
+ registry_username: ${{ github.actor }}
+ registry_password: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
diff --git a/.github/workflows/i18n-unused-keys.yml b/.github/workflows/i18n-unused-keys.yml
new file mode 100644
index 0000000000..8f773532d3
--- /dev/null
+++ b/.github/workflows/i18n-unused-keys.yml
@@ -0,0 +1,149 @@
+name: Detect Unused i18next Strings
+
+# This workflow checks for unused i18n keys in translation files.
+# It has special handling for:
+# - com_ui_special_var_* keys that are dynamically constructed
+# - com_agents_category_* keys that are stored in the database and used dynamically
+
+on:
+ pull_request:
+ paths:
+ - "client/src/**"
+ - "api/**"
+ - "packages/data-provider/src/**"
+ - "packages/client/**"
+ - "packages/data-schemas/src/**"
+
+jobs:
+ detect-unused-i18n-keys:
+ runs-on: ubuntu-latest
+ permissions:
+ pull-requests: write
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ - name: Find unused i18next keys
+ id: find-unused
+ run: |
+ echo "🔍 Scanning for unused i18next keys..."
+
+ # Define paths
+ I18N_FILE="client/src/locales/en/translation.json"
+ SOURCE_DIRS=("client/src" "api" "packages/data-provider/src" "packages/client" "packages/data-schemas/src")
+
+ # Check if translation file exists
+ if [[ ! -f "$I18N_FILE" ]]; then
+ echo "::error title=Missing i18n File::Translation file not found: $I18N_FILE"
+ exit 1
+ fi
+
+ # Extract all keys from the JSON file
+ KEYS=$(jq -r 'keys[]' "$I18N_FILE")
+
+ # Track unused keys
+ UNUSED_KEYS=()
+
+ # Check if each key is used in the source code
+ for KEY in $KEYS; do
+ FOUND=false
+
+ # 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
+ # Special case for agent category keys that are dynamically used from database
+ elif [[ "$KEY" == com_agents_category_* ]]; then
+ # Check if agent category localization is being used
+ for DIR in "${SOURCE_DIRS[@]}"; do
+ # Check for dynamic category label/description usage
+ if grep -r --include=\*.{js,jsx,ts,tsx} -E "category\.(label|description).*startsWith.*['\"]com_" "$DIR" > /dev/null 2>&1 || \
+ # Check for the method that defines these keys
+ grep -r --include=\*.{js,jsx,ts,tsx} "ensureDefaultCategories" "$DIR" > /dev/null 2>&1 || \
+ # Check for direct usage in agentCategory.ts
+ grep -r --include=\*.ts -E "label:.*['\"]$KEY['\"]" "$DIR" > /dev/null 2>&1 || \
+ grep -r --include=\*.ts -E "description:.*['\"]$KEY['\"]" "$DIR" > /dev/null 2>&1; 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
+ 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")
+ fi
+ done
+
+ # Output results
+ if [[ ${#UNUSED_KEYS[@]} -gt 0 ]]; then
+ echo "🛑 Found ${#UNUSED_KEYS[@]} unused i18n keys:"
+ echo "unused_keys=$(echo "${UNUSED_KEYS[@]}" | jq -R -s -c 'split(" ")')" >> $GITHUB_ENV
+ for KEY in "${UNUSED_KEYS[@]}"; do
+ echo "::warning title=Unused i18n Key::'$KEY' is defined but not used in the codebase."
+ done
+ else
+ echo "✅ No unused i18n keys detected!"
+ echo "unused_keys=[]" >> $GITHUB_ENV
+ fi
+
+ - name: Post verified comment on PR
+ if: env.unused_keys != '[]'
+ run: |
+ PR_NUMBER=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
+
+ # Format the unused keys list as checkboxes for easy manual checking.
+ FILTERED_KEYS=$(echo "$unused_keys" | jq -r '.[]' | grep -v '^\s*$' | sed 's/^/- [ ] `/;s/$/`/' )
+
+ COMMENT_BODY=$(cat <> $GITHUB_ENV
+
+ # Set up QEMU
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ # Set up Docker Buildx
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ # Log in to GitHub Container Registry
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v2
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # Login to Docker Hub
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ # Prepare the environment
+ - name: Prepare environment
+ run: |
+ cp .env.example .env
+
+ # Build and push Docker images for each target
+ - name: Build and push Docker images
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ${{ matrix.file }}
+ push: true
+ tags: |
+ ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ env.LATEST_TAG }}
+ ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ env.LATEST_TAG }}
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
+ platforms: linux/amd64,linux/arm64
+ target: ${{ matrix.target }}
diff --git a/.github/workflows/tag-images.yml b/.github/workflows/tag-images.yml
new file mode 100644
index 0000000000..e90f43978a
--- /dev/null
+++ b/.github/workflows/tag-images.yml
@@ -0,0 +1,67 @@
+name: Docker Images Build on Tag
+
+on:
+ push:
+ tags:
+ - '*'
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ include:
+ - target: api-build
+ file: Dockerfile.multi
+ image_name: librechat-api
+ - target: node
+ file: Dockerfile
+ image_name: librechat
+
+ steps:
+ # Check out the repository
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ # Set up QEMU
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ # Set up Docker Buildx
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ # Log in to GitHub Container Registry
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v2
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # Login to Docker Hub
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ # Prepare the environment
+ - name: Prepare environment
+ run: |
+ cp .env.example .env
+
+ # Build and push Docker images for each target
+ - name: Build and push Docker images
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ${{ matrix.file }}
+ push: true
+ tags: |
+ ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.ref_name }}
+ ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.ref_name }}
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
+ platforms: linux/amd64,linux/arm64
+ target: ${{ matrix.target }}
diff --git a/.github/workflows/unused-packages.yml b/.github/workflows/unused-packages.yml
new file mode 100644
index 0000000000..f67c1d23be
--- /dev/null
+++ b/.github/workflows/unused-packages.yml
@@ -0,0 +1,281 @@
+name: Detect Unused NPM Packages
+
+on:
+ pull_request:
+ paths:
+ - 'package.json'
+ - 'package-lock.json'
+ - 'client/**'
+ - 'api/**'
+ - 'packages/client/**'
+ - 'packages/api/**'
+
+jobs:
+ detect-unused-packages:
+ runs-on: ubuntu-latest
+ permissions:
+ pull-requests: write
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 20.x
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: 'npm'
+
+ - name: Install depcheck
+ run: npm install -g depcheck
+
+ - name: Validate JSON files
+ run: |
+ for FILE in package.json client/package.json api/package.json packages/client/package.json; do
+ if [[ -f "$FILE" ]]; then
+ jq empty "$FILE" || (echo "::error title=Invalid JSON::$FILE is invalid" && exit 1)
+ fi
+ done
+
+ - name: Extract Dependencies Used in Scripts
+ id: extract-used-scripts
+ run: |
+ extract_deps_from_scripts() {
+ local package_file=$1
+ if [[ -f "$package_file" ]]; then
+ jq -r '.scripts | to_entries[].value' "$package_file" | \
+ grep -oE '([a-zA-Z0-9_-]+)' | sort -u > used_scripts.txt
+ else
+ touch used_scripts.txt
+ fi
+ }
+
+ extract_deps_from_scripts "package.json"
+ mv used_scripts.txt root_used_deps.txt
+
+ extract_deps_from_scripts "client/package.json"
+ mv used_scripts.txt client_used_deps.txt
+
+ extract_deps_from_scripts "api/package.json"
+ mv used_scripts.txt api_used_deps.txt
+
+ - name: Extract Dependencies Used in Source Code
+ id: extract-used-code
+ run: |
+ extract_deps_from_code() {
+ local folder=$1
+ local output_file=$2
+
+ # Initialize empty output file
+ > "$output_file"
+
+ if [[ -d "$folder" ]]; then
+ # Extract require() statements (use explicit includes for portability)
+ grep -rEho "require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)" "$folder" \
+ --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
+ sed -E "s/require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)/\1/" >> "$output_file" || true
+
+ # Extract ES6 imports - import x from 'module'
+ grep -rEho "import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
+ --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
+ sed -E "s/import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
+
+ # import 'module' (side-effect imports)
+ grep -rEho "import ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
+ --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
+ sed -E "s/import ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
+
+ # export { x } from 'module' or export * from 'module'
+ grep -rEho "export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
+ --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
+ sed -E "s/export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
+
+ # import type { x } from 'module' (TypeScript)
+ grep -rEho "import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
+ --include='*.ts' --include='*.tsx' 2>/dev/null | \
+ sed -E "s/import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
+
+ # Remove subpath imports but keep the base package
+ # For scoped packages: '@scope/pkg/subpath' -> '@scope/pkg'
+ # For regular packages: 'pkg/subpath' -> 'pkg'
+ # Scoped packages (must keep @scope/package, strip anything after)
+ sed -i -E 's|^(@[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/.*|\1|' "$output_file" 2>/dev/null || true
+ # Non-scoped packages (keep package name, strip subpath)
+ sed -i -E 's|^([a-zA-Z0-9_-]+)/.*|\1|' "$output_file" 2>/dev/null || true
+
+ sort -u "$output_file" -o "$output_file"
+ fi
+ }
+
+ extract_deps_from_code "." root_used_code.txt
+ extract_deps_from_code "client" client_used_code.txt
+ extract_deps_from_code "api" api_used_code.txt
+
+ # Extract dependencies used by workspace packages
+ # These packages are used in the workspace but dependencies are provided by parent package.json
+ extract_deps_from_code "packages/client" packages_client_used_code.txt
+ extract_deps_from_code "packages/api" packages_api_used_code.txt
+
+ - name: Get @librechat/client dependencies
+ id: get-librechat-client-deps
+ run: |
+ if [[ -f "packages/client/package.json" ]]; then
+ # Get all dependencies from @librechat/client (dependencies, devDependencies, and peerDependencies)
+ DEPS=$(jq -r '.dependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "")
+ DEV_DEPS=$(jq -r '.devDependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "")
+ PEER_DEPS=$(jq -r '.peerDependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "")
+
+ # Combine all dependencies
+ echo "$DEPS" > librechat_client_deps.txt
+ echo "$DEV_DEPS" >> librechat_client_deps.txt
+ echo "$PEER_DEPS" >> librechat_client_deps.txt
+
+ # Also include dependencies that are imported in packages/client
+ cat packages_client_used_code.txt >> librechat_client_deps.txt
+
+ # Remove empty lines and sort
+ grep -v '^$' librechat_client_deps.txt | sort -u > temp_deps.txt
+ mv temp_deps.txt librechat_client_deps.txt
+ else
+ touch librechat_client_deps.txt
+ fi
+
+ - name: Get @librechat/api dependencies
+ id: get-librechat-api-deps
+ run: |
+ if [[ -f "packages/api/package.json" ]]; then
+ # Get all dependencies from @librechat/api (dependencies, devDependencies, and peerDependencies)
+ DEPS=$(jq -r '.dependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "")
+ DEV_DEPS=$(jq -r '.devDependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "")
+ PEER_DEPS=$(jq -r '.peerDependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "")
+
+ # Combine all dependencies
+ echo "$DEPS" > librechat_api_deps.txt
+ echo "$DEV_DEPS" >> librechat_api_deps.txt
+ echo "$PEER_DEPS" >> librechat_api_deps.txt
+
+ # Also include dependencies that are imported in packages/api
+ cat packages_api_used_code.txt >> librechat_api_deps.txt
+
+ # Remove empty lines and sort
+ grep -v '^$' librechat_api_deps.txt | sort -u > temp_deps.txt
+ mv temp_deps.txt librechat_api_deps.txt
+ else
+ touch librechat_api_deps.txt
+ fi
+
+ - name: Extract Workspace Dependencies
+ id: extract-workspace-deps
+ run: |
+ # Function to get dependencies from a workspace package that are used by another package
+ get_workspace_package_deps() {
+ local package_json=$1
+ local output_file=$2
+
+ # Get all workspace dependencies (starting with @librechat/)
+ if [[ -f "$package_json" ]]; then
+ local workspace_deps=$(jq -r '.dependencies // {} | to_entries[] | select(.key | startswith("@librechat/")) | .key' "$package_json" 2>/dev/null || echo "")
+
+ # For each workspace dependency, get its dependencies
+ for dep in $workspace_deps; do
+ # Convert @librechat/api to packages/api
+ local workspace_path=$(echo "$dep" | sed 's/@librechat\//packages\//')
+ local workspace_package_json="${workspace_path}/package.json"
+
+ if [[ -f "$workspace_package_json" ]]; then
+ # Extract all dependencies from the workspace package
+ jq -r '.dependencies // {} | keys[]' "$workspace_package_json" 2>/dev/null >> "$output_file"
+ # Also extract peerDependencies
+ jq -r '.peerDependencies // {} | keys[]' "$workspace_package_json" 2>/dev/null >> "$output_file"
+ fi
+ done
+ fi
+
+ if [[ -f "$output_file" ]]; then
+ sort -u "$output_file" -o "$output_file"
+ else
+ touch "$output_file"
+ fi
+ }
+
+ # Get workspace dependencies for each package
+ get_workspace_package_deps "package.json" root_workspace_deps.txt
+ get_workspace_package_deps "client/package.json" client_workspace_deps.txt
+ get_workspace_package_deps "api/package.json" api_workspace_deps.txt
+
+ - name: Run depcheck for root package.json
+ id: check-root
+ run: |
+ if [[ -f "package.json" ]]; then
+ UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
+ # Exclude dependencies used in scripts, code, and workspace packages
+ UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat root_used_deps.txt root_used_code.txt root_workspace_deps.txt | sort) || echo "")
+ echo "ROOT_UNUSED<> $GITHUB_ENV
+ echo "$UNUSED" >> $GITHUB_ENV
+ echo "EOF" >> $GITHUB_ENV
+ fi
+
+ - name: Run depcheck for client/package.json
+ id: check-client
+ run: |
+ if [[ -f "client/package.json" ]]; then
+ chmod -R 755 client
+ cd client
+ UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
+ # Exclude dependencies used in scripts, code, workspace packages, and @librechat/client imports
+ UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../client_used_deps.txt ../client_used_code.txt ../client_workspace_deps.txt ../packages_client_used_code.txt ../librechat_client_deps.txt 2>/dev/null | sort -u) || echo "")
+ # Filter out false positives
+ UNUSED=$(echo "$UNUSED" | grep -v "^micromark-extension-llm-math$" || echo "")
+ echo "CLIENT_UNUSED<> $GITHUB_ENV
+ echo "$UNUSED" >> $GITHUB_ENV
+ echo "EOF" >> $GITHUB_ENV
+ cd ..
+ fi
+
+ - name: Run depcheck for api/package.json
+ id: check-api
+ run: |
+ if [[ -f "api/package.json" ]]; then
+ chmod -R 755 api
+ cd api
+ UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
+ # Exclude dependencies used in scripts, code, workspace packages, and @librechat/api imports
+ UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../api_used_deps.txt ../api_used_code.txt ../api_workspace_deps.txt ../packages_api_used_code.txt ../librechat_api_deps.txt 2>/dev/null | sort -u) || echo "")
+ echo "API_UNUSED<> $GITHUB_ENV
+ echo "$UNUSED" >> $GITHUB_ENV
+ echo "EOF" >> $GITHUB_ENV
+ cd ..
+ fi
+
+ - name: Post comment on PR if unused dependencies are found
+ if: env.ROOT_UNUSED != '' || env.CLIENT_UNUSED != '' || env.API_UNUSED != ''
+ run: |
+ PR_NUMBER=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
+
+ ROOT_LIST=$(echo "$ROOT_UNUSED" | awk '{print "- `" $0 "`"}')
+ CLIENT_LIST=$(echo "$CLIENT_UNUSED" | awk '{print "- `" $0 "`"}')
+ API_LIST=$(echo "$API_UNUSED" | awk '{print "- `" $0 "`"}')
+
+ COMMENT_BODY=$(cat </**"],
+ "program": "${workspaceFolder}/api/server/index.js",
+ "env": {
+ "NODE_ENV": "production",
+ "NODE_TLS_REJECT_UNAUTHORIZED": "0"
+ },
+ "console": "integratedTerminal",
+ "envFile": "${workspaceFolder}/.env"
+ }
+ ]
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000000..a8cb8282bd
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,236 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+
+
+
+
+
+## [Unreleased]
+
+### ✨ New Features
+
+- ✨ feat: implement search parameter updates by **@mawburn** in [#7151](https://github.com/danny-avila/LibreChat/pull/7151)
+- 🎏 feat: Add MCP support for Streamable HTTP Transport by **@benverhees** in [#7353](https://github.com/danny-avila/LibreChat/pull/7353)
+- 🔒 feat: Add Content Security Policy using Helmet middleware by **@rubentalstra** in [#7377](https://github.com/danny-avila/LibreChat/pull/7377)
+- ✨ feat: Add Normalization for MCP Server Names by **@danny-avila** in [#7421](https://github.com/danny-avila/LibreChat/pull/7421)
+- 📊 feat: Improve Helm Chart by **@hofq** in [#3638](https://github.com/danny-avila/LibreChat/pull/3638)
+- 🦾 feat: Claude-4 Support by **@danny-avila** in [#7509](https://github.com/danny-avila/LibreChat/pull/7509)
+- 🪨 feat: Bedrock Support for Claude-4 Reasoning by **@danny-avila** in [#7517](https://github.com/danny-avila/LibreChat/pull/7517)
+
+### 🌍 Internationalization
+
+- 🌍 i18n: Add `Danish` and `Czech` and `Catalan` localization support by **@rubentalstra** in [#7373](https://github.com/danny-avila/LibreChat/pull/7373)
+- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7375](https://github.com/danny-avila/LibreChat/pull/7375)
+- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7468](https://github.com/danny-avila/LibreChat/pull/7468)
+
+### 🔧 Fixes
+
+- 💬 fix: update aria-label for accessibility in ConvoLink component by **@berry-13** in [#7320](https://github.com/danny-avila/LibreChat/pull/7320)
+- 🔑 fix: use `apiKey` instead of `openAIApiKey` in OpenAI-like Config by **@danny-avila** in [#7337](https://github.com/danny-avila/LibreChat/pull/7337)
+- 🔄 fix: update navigation logic in `useFocusChatEffect` to ensure correct search parameters are used by **@mawburn** in [#7340](https://github.com/danny-avila/LibreChat/pull/7340)
+- 🔄 fix: Improve MCP Connection Cleanup by **@danny-avila** in [#7400](https://github.com/danny-avila/LibreChat/pull/7400)
+- 🛡️ fix: Preset and Validation Logic for URL Query Params by **@danny-avila** in [#7407](https://github.com/danny-avila/LibreChat/pull/7407)
+- 🌘 fix: artifact of preview text is illegible in dark mode by **@nhtruong** in [#7405](https://github.com/danny-avila/LibreChat/pull/7405)
+- 🛡️ fix: Temporarily Remove CSP until Configurable by **@danny-avila** in [#7419](https://github.com/danny-avila/LibreChat/pull/7419)
+- 💽 fix: Exclude index page `/` from static cache settings by **@sbruel** in [#7382](https://github.com/danny-avila/LibreChat/pull/7382)
+
+### ⚙️ Other Changes
+
+- 📜 docs: CHANGELOG for release v0.7.8 by **@github-actions[bot]** in [#7290](https://github.com/danny-avila/LibreChat/pull/7290)
+- 📦 chore: Update API Package Dependencies by **@danny-avila** in [#7359](https://github.com/danny-avila/LibreChat/pull/7359)
+- 📜 docs: Unreleased Changelog by **@github-actions[bot]** in [#7321](https://github.com/danny-avila/LibreChat/pull/7321)
+- 📜 docs: Unreleased Changelog by **@github-actions[bot]** in [#7434](https://github.com/danny-avila/LibreChat/pull/7434)
+- 🛡️ chore: `multer` v2.0.0 for CVE-2025-47935 and CVE-2025-47944 by **@danny-avila** in [#7454](https://github.com/danny-avila/LibreChat/pull/7454)
+- 📂 refactor: Improve `FileAttachment` & File Form Deletion by **@danny-avila** in [#7471](https://github.com/danny-avila/LibreChat/pull/7471)
+- 📊 chore: Remove Old Helm Chart by **@hofq** in [#7512](https://github.com/danny-avila/LibreChat/pull/7512)
+- 🪖 chore: bump helm app version to v0.7.8 by **@austin-barrington** in [#7524](https://github.com/danny-avila/LibreChat/pull/7524)
+
+
+
+---
+## [v0.7.8] -
+
+Changes from v0.7.8-rc1 to v0.7.8.
+
+### ✨ New Features
+
+- ✨ feat: Enhance form submission for touch screens by **@berry-13** in [#7198](https://github.com/danny-avila/LibreChat/pull/7198)
+- 🔍 feat: Additional Tavily API Tool Parameters by **@glowforge-opensource** in [#7232](https://github.com/danny-avila/LibreChat/pull/7232)
+- 🐋 feat: Add python to Dockerfile for increased MCP compatibility by **@technicalpickles** in [#7270](https://github.com/danny-avila/LibreChat/pull/7270)
+
+### 🔧 Fixes
+
+- 🔧 fix: Google Gemma Support & OpenAI Reasoning Instructions by **@danny-avila** in [#7196](https://github.com/danny-avila/LibreChat/pull/7196)
+- 🛠️ fix: Conversation Navigation State by **@danny-avila** in [#7210](https://github.com/danny-avila/LibreChat/pull/7210)
+- 🔄 fix: o-Series Model Regex for System Messages by **@danny-avila** in [#7245](https://github.com/danny-avila/LibreChat/pull/7245)
+- 🔖 fix: Custom Headers for Initial MCP SSE Connection by **@danny-avila** in [#7246](https://github.com/danny-avila/LibreChat/pull/7246)
+- 🛡️ fix: Deep Clone `MCPOptions` for User MCP Connections by **@danny-avila** in [#7247](https://github.com/danny-avila/LibreChat/pull/7247)
+- 🔄 fix: URL Param Race Condition and File Draft Persistence by **@danny-avila** in [#7257](https://github.com/danny-avila/LibreChat/pull/7257)
+- 🔄 fix: Assistants Endpoint & Minor Issues by **@danny-avila** in [#7274](https://github.com/danny-avila/LibreChat/pull/7274)
+- 🔄 fix: Ollama Think Tag Edge Case with Tools by **@danny-avila** in [#7275](https://github.com/danny-avila/LibreChat/pull/7275)
+
+### ⚙️ Other Changes
+
+- 📜 docs: CHANGELOG for release v0.7.8-rc1 by **@github-actions[bot]** in [#7153](https://github.com/danny-avila/LibreChat/pull/7153)
+- 🔄 refactor: Artifact Visibility Management by **@danny-avila** in [#7181](https://github.com/danny-avila/LibreChat/pull/7181)
+- 📦 chore: Bump Package Security by **@danny-avila** in [#7183](https://github.com/danny-avila/LibreChat/pull/7183)
+- 🌿 refactor: Unmount Fork Popover on Hide for Better Performance by **@danny-avila** in [#7189](https://github.com/danny-avila/LibreChat/pull/7189)
+- 🧰 chore: ESLint configuration to enforce Prettier formatting rules by **@mawburn** in [#7186](https://github.com/danny-avila/LibreChat/pull/7186)
+- 🎨 style: Improve KaTeX Rendering for LaTeX Equations by **@andresgit** in [#7223](https://github.com/danny-avila/LibreChat/pull/7223)
+- 📝 docs: Update `.env.example` Google models by **@marlonka** in [#7254](https://github.com/danny-avila/LibreChat/pull/7254)
+- 💬 refactor: MCP Chat Visibility Option, Google Rates, Remove OpenAPI Plugins by **@danny-avila** in [#7286](https://github.com/danny-avila/LibreChat/pull/7286)
+- 📜 docs: Unreleased Changelog by **@github-actions[bot]** in [#7214](https://github.com/danny-avila/LibreChat/pull/7214)
+
+
+
+[See full release details][release-v0.7.8]
+
+[release-v0.7.8]: https://github.com/danny-avila/LibreChat/releases/tag/v0.7.8
+
+---
+## [v0.7.8-rc1] -
+
+Changes from v0.7.7 to v0.7.8-rc1.
+
+### ✨ New Features
+
+- 🔍 feat: Mistral OCR API / Upload Files as Text by **@danny-avila** in [#6274](https://github.com/danny-avila/LibreChat/pull/6274)
+- 🤖 feat: Support OpenAI Web Search models by **@danny-avila** in [#6313](https://github.com/danny-avila/LibreChat/pull/6313)
+- 🔗 feat: Agent Chain (Mixture-of-Agents) by **@danny-avila** in [#6374](https://github.com/danny-avila/LibreChat/pull/6374)
+- ⌛ feat: `initTimeout` for Slow Starting MCP Servers by **@perweij** in [#6383](https://github.com/danny-avila/LibreChat/pull/6383)
+- 🚀 feat: `S3` Integration for File handling and Image uploads by **@rubentalstra** in [#6142](https://github.com/danny-avila/LibreChat/pull/6142)
+- 🔒feat: Enable OpenID Auto-Redirect by **@leondape** in [#6066](https://github.com/danny-avila/LibreChat/pull/6066)
+- 🚀 feat: Integrate `Azure Blob Storage` for file handling and image uploads by **@rubentalstra** in [#6153](https://github.com/danny-avila/LibreChat/pull/6153)
+- 🚀 feat: Add support for custom `AWS` endpoint in `S3` by **@rubentalstra** in [#6431](https://github.com/danny-avila/LibreChat/pull/6431)
+- 🚀 feat: Add support for LDAP STARTTLS in LDAP authentication by **@rubentalstra** in [#6438](https://github.com/danny-avila/LibreChat/pull/6438)
+- 🚀 feat: Refactor schema exports and update package version to 0.0.4 by **@rubentalstra** in [#6455](https://github.com/danny-avila/LibreChat/pull/6455)
+- 🔼 feat: Add Auto Submit For URL Query Params by **@mjaverto** in [#6440](https://github.com/danny-avila/LibreChat/pull/6440)
+- 🛠 feat: Enhance Redis Integration, Rate Limiters & Log Headers by **@danny-avila** in [#6462](https://github.com/danny-avila/LibreChat/pull/6462)
+- 💵 feat: Add Automatic Balance Refill by **@rubentalstra** in [#6452](https://github.com/danny-avila/LibreChat/pull/6452)
+- 🗣️ feat: add support for gpt-4o-transcribe models by **@berry-13** in [#6483](https://github.com/danny-avila/LibreChat/pull/6483)
+- 🎨 feat: UI Refresh for Enhanced UX by **@berry-13** in [#6346](https://github.com/danny-avila/LibreChat/pull/6346)
+- 🌍 feat: Add support for Hungarian language localization by **@rubentalstra** in [#6508](https://github.com/danny-avila/LibreChat/pull/6508)
+- 🚀 feat: Add Gemini 2.5 Token/Context Values, Increase Max Possible Output to 64k by **@danny-avila** in [#6563](https://github.com/danny-avila/LibreChat/pull/6563)
+- 🚀 feat: Enhance MCP Connections For Multi-User Support by **@danny-avila** in [#6610](https://github.com/danny-avila/LibreChat/pull/6610)
+- 🚀 feat: Enhance S3 URL Expiry with Refresh; fix: S3 File Deletion by **@danny-avila** in [#6647](https://github.com/danny-avila/LibreChat/pull/6647)
+- 🚀 feat: enhance UI components and refactor settings by **@berry-13** in [#6625](https://github.com/danny-avila/LibreChat/pull/6625)
+- 💬 feat: move TemporaryChat to the Header by **@berry-13** in [#6646](https://github.com/danny-avila/LibreChat/pull/6646)
+- 🚀 feat: Use Model Specs + Specific Endpoints, Limit Providers for Agents by **@danny-avila** in [#6650](https://github.com/danny-avila/LibreChat/pull/6650)
+- 🪙 feat: Sync Balance Config on Login by **@danny-avila** in [#6671](https://github.com/danny-avila/LibreChat/pull/6671)
+- 🔦 feat: MCP Support for Non-Agent Endpoints by **@danny-avila** in [#6775](https://github.com/danny-avila/LibreChat/pull/6775)
+- 🗃️ feat: Code Interpreter File Persistence between Sessions by **@danny-avila** in [#6790](https://github.com/danny-avila/LibreChat/pull/6790)
+- 🖥️ feat: Code Interpreter API for Non-Agent Endpoints by **@danny-avila** in [#6803](https://github.com/danny-avila/LibreChat/pull/6803)
+- ⚡ feat: Self-hosted Artifacts Static Bundler URL by **@danny-avila** in [#6827](https://github.com/danny-avila/LibreChat/pull/6827)
+- 🐳 feat: Add Jemalloc and UV to Docker Builds by **@danny-avila** in [#6836](https://github.com/danny-avila/LibreChat/pull/6836)
+- 🤖 feat: GPT-4.1 by **@danny-avila** in [#6880](https://github.com/danny-avila/LibreChat/pull/6880)
+- 👋 feat: remove Edge TTS by **@berry-13** in [#6885](https://github.com/danny-avila/LibreChat/pull/6885)
+- feat: nav optimization by **@berry-13** in [#5785](https://github.com/danny-avila/LibreChat/pull/5785)
+- 🗺️ feat: Add Parameter Location Mapping for OpenAPI actions by **@peeeteeer** in [#6858](https://github.com/danny-avila/LibreChat/pull/6858)
+- 🤖 feat: Support `o4-mini` and `o3` Models by **@danny-avila** in [#6928](https://github.com/danny-avila/LibreChat/pull/6928)
+- 🎨 feat: OpenAI Image Tools (GPT-Image-1) by **@danny-avila** in [#7079](https://github.com/danny-avila/LibreChat/pull/7079)
+- 🗓️ feat: Add Special Variables for Prompts & Agents, Prompt UI Improvements by **@danny-avila** in [#7123](https://github.com/danny-avila/LibreChat/pull/7123)
+
+### 🌍 Internationalization
+
+- 🌍 i18n: Add Thai Language Support and Update Translations by **@rubentalstra** in [#6219](https://github.com/danny-avila/LibreChat/pull/6219)
+- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6220](https://github.com/danny-avila/LibreChat/pull/6220)
+- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6240](https://github.com/danny-avila/LibreChat/pull/6240)
+- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6241](https://github.com/danny-avila/LibreChat/pull/6241)
+- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6277](https://github.com/danny-avila/LibreChat/pull/6277)
+- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6414](https://github.com/danny-avila/LibreChat/pull/6414)
+- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6505](https://github.com/danny-avila/LibreChat/pull/6505)
+- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6530](https://github.com/danny-avila/LibreChat/pull/6530)
+- 🌍 i18n: Add Persian Localization Support by **@rubentalstra** in [#6669](https://github.com/danny-avila/LibreChat/pull/6669)
+- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6667](https://github.com/danny-avila/LibreChat/pull/6667)
+- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7126](https://github.com/danny-avila/LibreChat/pull/7126)
+- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7148](https://github.com/danny-avila/LibreChat/pull/7148)
+
+### 👐 Accessibility
+
+- 🎨 a11y: Update Model Spec Description Text by **@berry-13** in [#6294](https://github.com/danny-avila/LibreChat/pull/6294)
+- 🗑️ a11y: Add Accessible Name to Button for File Attachment Removal by **@kangabell** in [#6709](https://github.com/danny-avila/LibreChat/pull/6709)
+- ⌨️ a11y: enhance accessibility & visual consistency by **@berry-13** in [#6866](https://github.com/danny-avila/LibreChat/pull/6866)
+- 🙌 a11y: Searchbar/Conversations List Focus by **@danny-avila** in [#7096](https://github.com/danny-avila/LibreChat/pull/7096)
+- 👐 a11y: Improve Fork and SplitText Accessibility by **@danny-avila** in [#7147](https://github.com/danny-avila/LibreChat/pull/7147)
+
+### 🔧 Fixes
+
+- 🐛 fix: Avatar Type Definitions in Agent/Assistant Schemas by **@danny-avila** in [#6235](https://github.com/danny-avila/LibreChat/pull/6235)
+- 🔧 fix: MeiliSearch Field Error and Patch Incorrect Import by #6210 by **@rubentalstra** in [#6245](https://github.com/danny-avila/LibreChat/pull/6245)
+- 🔏 fix: Enhance Two-Factor Authentication by **@rubentalstra** in [#6247](https://github.com/danny-avila/LibreChat/pull/6247)
+- 🐛 fix: Await saveMessage in abortMiddleware to ensure proper execution by **@sh4shii** in [#6248](https://github.com/danny-avila/LibreChat/pull/6248)
+- 🔧 fix: Axios Proxy Usage And Bump `mongoose` by **@danny-avila** in [#6298](https://github.com/danny-avila/LibreChat/pull/6298)
+- 🔧 fix: comment out MCP servers to resolve service run issues by **@KunalScriptz** in [#6316](https://github.com/danny-avila/LibreChat/pull/6316)
+- 🔧 fix: Update Token Calculations and Mapping, MCP `env` Initialization by **@danny-avila** in [#6406](https://github.com/danny-avila/LibreChat/pull/6406)
+- 🐞 fix: Agent "Resend" Message Attachments + Source Icon Styling by **@danny-avila** in [#6408](https://github.com/danny-avila/LibreChat/pull/6408)
+- 🐛 fix: Prevent Crash on Duplicate Message ID by **@Odrec** in [#6392](https://github.com/danny-avila/LibreChat/pull/6392)
+- 🔐 fix: Invalid Key Length in 2FA Encryption by **@rubentalstra** in [#6432](https://github.com/danny-avila/LibreChat/pull/6432)
+- 🏗️ fix: Fix Agents Token Spend Race Conditions, Expand Test Coverage by **@danny-avila** in [#6480](https://github.com/danny-avila/LibreChat/pull/6480)
+- 🔃 fix: Draft Clearing, Claude Titles, Remove Default Vision Max Tokens by **@danny-avila** in [#6501](https://github.com/danny-avila/LibreChat/pull/6501)
+- 🔧 fix: Update username reference to use user.name in greeting display by **@rubentalstra** in [#6534](https://github.com/danny-avila/LibreChat/pull/6534)
+- 🔧 fix: S3 Download Stream with Key Extraction and Blob Storage Encoding for Vision by **@danny-avila** in [#6557](https://github.com/danny-avila/LibreChat/pull/6557)
+- 🔧 fix: Mistral type strictness for `usage` & update token values/windows by **@danny-avila** in [#6562](https://github.com/danny-avila/LibreChat/pull/6562)
+- 🔧 fix: Consolidate Text Parsing and TTS Edge Initialization by **@danny-avila** in [#6582](https://github.com/danny-avila/LibreChat/pull/6582)
+- 🔧 fix: Ensure continuation in image processing on base64 encoding from Blob Storage by **@danny-avila** in [#6619](https://github.com/danny-avila/LibreChat/pull/6619)
+- ✉️ fix: Fallback For User Name In Email Templates by **@danny-avila** in [#6620](https://github.com/danny-avila/LibreChat/pull/6620)
+- 🔧 fix: Azure Blob Integration and File Source References by **@rubentalstra** in [#6575](https://github.com/danny-avila/LibreChat/pull/6575)
+- 🐛 fix: Safeguard against undefined addedEndpoints by **@wipash** in [#6654](https://github.com/danny-avila/LibreChat/pull/6654)
+- 🤖 fix: Gemini 2.5 Vision Support by **@danny-avila** in [#6663](https://github.com/danny-avila/LibreChat/pull/6663)
+- 🔄 fix: Avatar & Error Handling Enhancements by **@danny-avila** in [#6687](https://github.com/danny-avila/LibreChat/pull/6687)
+- 🔧 fix: Chat Middleware, Zod Conversion, Auto-Save and S3 URL Refresh by **@danny-avila** in [#6720](https://github.com/danny-avila/LibreChat/pull/6720)
+- 🔧 fix: Agent Capability Checks & DocumentDB Compatibility for Agent Resource Removal by **@danny-avila** in [#6726](https://github.com/danny-avila/LibreChat/pull/6726)
+- 🔄 fix: Improve audio MIME type detection and handling by **@berry-13** in [#6707](https://github.com/danny-avila/LibreChat/pull/6707)
+- 🪺 fix: Update Role Handling due to New Schema Shape by **@danny-avila** in [#6774](https://github.com/danny-avila/LibreChat/pull/6774)
+- 🗨️ fix: Show ModelSpec Greeting by **@berry-13** in [#6770](https://github.com/danny-avila/LibreChat/pull/6770)
+- 🔧 fix: Keyv and Proxy Issues, and More Memory Optimizations by **@danny-avila** in [#6867](https://github.com/danny-avila/LibreChat/pull/6867)
+- ✨ fix: Implement dynamic text sizing for greeting and name display by **@berry-13** in [#6833](https://github.com/danny-avila/LibreChat/pull/6833)
+- 📝 fix: Mistral OCR Image Support and Azure Agent Titles by **@danny-avila** in [#6901](https://github.com/danny-avila/LibreChat/pull/6901)
+- 📢 fix: Invalid `engineTTS` and Conversation State on Navigation by **@berry-13** in [#6904](https://github.com/danny-avila/LibreChat/pull/6904)
+- 🛠️ fix: Improve Accessibility and Display of Conversation Menu by **@danny-avila** in [#6913](https://github.com/danny-avila/LibreChat/pull/6913)
+- 🔧 fix: Agent Resource Form, Convo Menu Style, Ensure Draft Clears on Submission by **@danny-avila** in [#6925](https://github.com/danny-avila/LibreChat/pull/6925)
+- 🔀 fix: MCP Improvements, Auto-Save Drafts, Artifact Markup by **@danny-avila** in [#7040](https://github.com/danny-avila/LibreChat/pull/7040)
+- 🐋 fix: Improve Deepseek Compatbility by **@danny-avila** in [#7132](https://github.com/danny-avila/LibreChat/pull/7132)
+- 🐙 fix: Add Redis Ping Interval to Prevent Connection Drops by **@peeeteeer** in [#7127](https://github.com/danny-avila/LibreChat/pull/7127)
+
+### ⚙️ Other Changes
+
+- 📦 refactor: Move DB Models to `@librechat/data-schemas` by **@rubentalstra** in [#6210](https://github.com/danny-avila/LibreChat/pull/6210)
+- 📦 chore: Patch `axios` to address CVE-2025-27152 by **@danny-avila** in [#6222](https://github.com/danny-avila/LibreChat/pull/6222)
+- ⚠️ refactor: Use Error Content Part Instead Of Throwing Error for Agents by **@danny-avila** in [#6262](https://github.com/danny-avila/LibreChat/pull/6262)
+- 🏃♂️ refactor: Improve Agent Run Context & Misc. Changes by **@danny-avila** in [#6448](https://github.com/danny-avila/LibreChat/pull/6448)
+- 📝 docs: librechat.example.yaml by **@ineiti** in [#6442](https://github.com/danny-avila/LibreChat/pull/6442)
+- 🏃♂️ refactor: More Agent Context Improvements during Run by **@danny-avila** in [#6477](https://github.com/danny-avila/LibreChat/pull/6477)
+- 🔃 refactor: Allow streaming for `o1` models by **@danny-avila** in [#6509](https://github.com/danny-avila/LibreChat/pull/6509)
+- 🔧 chore: `Vite` Plugin Upgrades & Config Optimizations by **@rubentalstra** in [#6547](https://github.com/danny-avila/LibreChat/pull/6547)
+- 🔧 refactor: Consolidate Logging, Model Selection & Actions Optimizations, Minor Fixes by **@danny-avila** in [#6553](https://github.com/danny-avila/LibreChat/pull/6553)
+- 🎨 style: Address Minor UI Refresh Issues by **@berry-13** in [#6552](https://github.com/danny-avila/LibreChat/pull/6552)
+- 🔧 refactor: Enhance Model & Endpoint Configurations with Global Indicators 🌍 by **@berry-13** in [#6578](https://github.com/danny-avila/LibreChat/pull/6578)
+- 💬 style: Chat UI, Greeting, and Message adjustments by **@berry-13** in [#6612](https://github.com/danny-avila/LibreChat/pull/6612)
+- ⚡ refactor: DocumentDB Compatibility for Balance Updates by **@danny-avila** in [#6673](https://github.com/danny-avila/LibreChat/pull/6673)
+- 🧹 chore: Update ESLint rules for React hooks by **@rubentalstra** in [#6685](https://github.com/danny-avila/LibreChat/pull/6685)
+- 🪙 chore: Update Gemini Pricing by **@RedwindA** in [#6731](https://github.com/danny-avila/LibreChat/pull/6731)
+- 🪺 refactor: Nest Permission fields for Roles by **@rubentalstra** in [#6487](https://github.com/danny-avila/LibreChat/pull/6487)
+- 📦 chore: Update `caniuse-lite` dependency to version 1.0.30001706 by **@rubentalstra** in [#6482](https://github.com/danny-avila/LibreChat/pull/6482)
+- ⚙️ refactor: OAuth Flow Signal, Type Safety, Tool Progress & Updated Packages by **@danny-avila** in [#6752](https://github.com/danny-avila/LibreChat/pull/6752)
+- 📦 chore: bump vite from 6.2.3 to 6.2.5 by **@dependabot[bot]** in [#6745](https://github.com/danny-avila/LibreChat/pull/6745)
+- 💾 chore: Enhance Local Storage Handling and Update MCP SDK by **@danny-avila** in [#6809](https://github.com/danny-avila/LibreChat/pull/6809)
+- 🤖 refactor: Improve Agents Memory Usage, Bump Keyv, Grok 3 by **@danny-avila** in [#6850](https://github.com/danny-avila/LibreChat/pull/6850)
+- 💾 refactor: Enhance Memory In Image Encodings & Client Disposal by **@danny-avila** in [#6852](https://github.com/danny-avila/LibreChat/pull/6852)
+- 🔁 refactor: Token Event Handler and Standardize `maxTokens` Key by **@danny-avila** in [#6886](https://github.com/danny-avila/LibreChat/pull/6886)
+- 🔍 refactor: Search & Message Retrieval by **@berry-13** in [#6903](https://github.com/danny-avila/LibreChat/pull/6903)
+- 🎨 style: standardize dropdown styling & fix z-Index layering by **@berry-13** in [#6939](https://github.com/danny-avila/LibreChat/pull/6939)
+- 📙 docs: CONTRIBUTING.md by **@dblock** in [#6831](https://github.com/danny-avila/LibreChat/pull/6831)
+- 🧭 refactor: Modernize Nav/Header by **@danny-avila** in [#7094](https://github.com/danny-avila/LibreChat/pull/7094)
+- 🪶 refactor: Chat Input Focus for Conversation Navigations & ChatForm Optimizations by **@danny-avila** in [#7100](https://github.com/danny-avila/LibreChat/pull/7100)
+- 🔃 refactor: Streamline Navigation, Message Loading UX by **@danny-avila** in [#7118](https://github.com/danny-avila/LibreChat/pull/7118)
+- 📜 docs: Unreleased changelog by **@github-actions[bot]** in [#6265](https://github.com/danny-avila/LibreChat/pull/6265)
+
+
+
+[See full release details][release-v0.7.8-rc1]
+
+[release-v0.7.8-rc1]: https://github.com/danny-avila/LibreChat/releases/tag/v0.7.8-rc1
+
+---
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000..b5e1b73819
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,57 @@
+# v0.8.2-rc1
+
+# Base node image
+FROM node:20-alpine AS node
+
+# 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.9.5-python3.12-alpine /usr/local/bin/uv /usr/local/bin/uvx /bin/
+RUN uv --version
+
+RUN mkdir -p /app && chown node:node /app
+WORKDIR /app
+
+USER node
+
+COPY --chown=node:node package.json package-lock.json ./
+COPY --chown=node:node api/package.json ./api/package.json
+COPY --chown=node:node client/package.json ./client/package.json
+COPY --chown=node:node packages/data-provider/package.json ./packages/data-provider/package.json
+COPY --chown=node:node packages/data-schemas/package.json ./packages/data-schemas/package.json
+COPY --chown=node:node packages/api/package.json ./packages/api/package.json
+
+RUN \
+ # Allow mounting of these files, which have no default
+ touch .env ; \
+ # Create directories for the volumes to inherit the correct permissions
+ mkdir -p /app/client/public/images /app/api/logs /app/uploads ; \
+ npm config set fetch-retry-maxtimeout 600000 ; \
+ npm config set fetch-retries 5 ; \
+ npm config set fetch-retry-mintimeout 15000 ; \
+ npm ci --no-audit
+
+COPY --chown=node:node . .
+
+RUN \
+ # React client build
+ NODE_OPTIONS="--max-old-space-size=2048" npm run frontend; \
+ npm prune --production; \
+ npm cache clean --force
+
+# Node API setup
+EXPOSE 3080
+ENV HOST=0.0.0.0
+CMD ["npm", "run", "backend"]
+
+# Optional: for client with nginx routing
+# FROM nginx:stable-alpine AS nginx-client
+# 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;"]
diff --git a/Dockerfile.multi b/Dockerfile.multi
new file mode 100644
index 0000000000..89a07087f0
--- /dev/null
+++ b/Dockerfile.multi
@@ -0,0 +1,82 @@
+# Dockerfile.multi
+# v0.8.2-rc1
+
+# 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 && \
+ npm config set fetch-retries 5 && \
+ npm config set fetch-retry-mintimeout 15000
+COPY package*.json ./
+COPY packages/data-provider/package*.json ./packages/data-provider/
+COPY packages/api/package*.json ./packages/api/
+COPY packages/data-schemas/package*.json ./packages/data-schemas/
+COPY packages/client/package*.json ./packages/client/
+COPY client/package*.json ./client/
+COPY api/package*.json ./api/
+
+# Install all dependencies for every build
+FROM base-min AS base
+WORKDIR /app
+RUN npm ci
+
+# Build `data-provider` package
+FROM base AS data-provider-build
+WORKDIR /app/packages/data-provider
+COPY packages/data-provider ./
+RUN npm run build
+
+# Build `data-schemas` package
+FROM base AS data-schemas-build
+WORKDIR /app/packages/data-schemas
+COPY packages/data-schemas ./
+COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist
+RUN npm run build
+
+# Build `api` package
+FROM base AS api-package-build
+WORKDIR /app/packages/api
+COPY packages/api ./
+COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist
+COPY --from=data-schemas-build /app/packages/data-schemas/dist /app/packages/data-schemas/dist
+RUN npm run build
+
+# Build `client` package
+FROM base AS client-package-build
+WORKDIR /app/packages/client
+COPY packages/client ./
+RUN npm run build
+
+# Client build
+FROM base AS client-build
+WORKDIR /app/client
+COPY client ./
+COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist
+COPY --from=client-package-build /app/packages/client/dist /app/packages/client/dist
+COPY --from=client-package-build /app/packages/client/src /app/packages/client/src
+ENV NODE_OPTIONS="--max-old-space-size=2048"
+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
+COPY api ./api
+COPY config ./config
+COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data-provider/dist
+COPY --from=data-schemas-build /app/packages/data-schemas/dist ./packages/data-schemas/dist
+COPY --from=api-package-build /app/packages/api/dist ./packages/api/dist
+COPY --from=client-build /app/client/dist ./client/dist
+WORKDIR /app/api
+EXPOSE 3080
+ENV HOST=0.0.0.0
+CMD ["node", "server/index.js"]
\ No newline at end of file
diff --git a/LICENSE.md b/LICENSE
similarity index 97%
rename from LICENSE.md
rename to LICENSE
index ee1ca3d9b4..535850a920 100644
--- a/LICENSE.md
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2023 Danny Avila
+Copyright (c) 2025 LibreChat
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index d0ca06a9ca..a96e47f70f 100644
--- a/README.md
+++ b/README.md
@@ -1,224 +1,217 @@
-# ChatGPT Clone #
-https://user-images.githubusercontent.com/110412045/223754183-8b7f45ce-6517-4bd5-9b39-c624745bf399.mp4
+
+
+
+
+
+
-## All AI Conversations under One Roof. ##
- Assistant AIs are the future and OpenAI revolutionized this movement with ChatGPT. While numerous methods exist to integrate them, this app commemorates the original styling of ChatGPT, with the ability to integrate any current/future AI models, while improving upon original client features, such as conversation search and prompt templates (currently WIP).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
- This project was started early in Feb '23, anticipating the release of the official ChatGPT API from OpenAI, and now uses it. Through this clone, you can avoid ChatGPT Plus in favor of free or pay-per-call APIs. I will soon deploy a demo of this app. Feel free to contribute, clone, or fork. Currently dockerized.
+
+
+
+
+
+
+
+
+
+
+
-## Updates
-
-2023-03-09
-Released v.0.0.2
-
-Adds Sydney (jailbroken Bing AI) to the model menu. Thank you [DavesDevFails](https://github.com/DavesDevFails) for bringing it to my attention in this [issue](https://github.com/danny-avila/chatgpt-clone/issues/13). Bing/Sydney now correctly cite links, more styling to come. Fix some overlooked bugs, and model menu doesn't close upon deleting a customGpt.
+
+
+
+
+
-I've re-enabled the ChatGPT browser client (free version) since it might be working for most people, it no longer works for me. Sydney is the best free route anyway.
-
+# ✨ Features
-
-
-2023-03-07
-Due to increased interest in the repo, I've dockerized the app as of this update for quick setup! See setup instructions below. I realize this still takes some time with installing docker dependencies, so it's on the roadmap to have a deployed demo. Besides this, I've made major improvements for a lot of the existing features across the board, mainly UI/UX.
+- 🖥️ **UI & Experience** inspired by ChatGPT with enhanced design and features
+- 🤖 **AI Model Selection**:
+ - Anthropic (Claude), AWS Bedrock, OpenAI, Azure OpenAI, Google, Vertex AI, OpenAI Responses API (incl. Azure)
+ - [Custom Endpoints](https://www.librechat.ai/docs/quick_start/custom_endpoints): Use any OpenAI-compatible API with LibreChat, no proxy required
+ - Compatible with [Local & Remote AI Providers](https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints):
+ - Ollama, groq, Cohere, Mistral AI, Apple MLX, koboldcpp, together.ai,
+ - OpenRouter, Helicone, Perplexity, ShuttleAI, Deepseek, Qwen, and more
-Also worth noting, the method to access the Free Version is no longer working, so I've removed it from model selection until further notice.
-
-Previous Updates
+- 🔧 **[Code Interpreter API](https://www.librechat.ai/docs/features/code_interpreter)**:
+ - Secure, Sandboxed Execution in Python, Node.js (JS/TS), Go, C/C++, Java, PHP, Rust, and Fortran
+ - Seamless File Handling: Upload, process, and download files directly
+ - No Privacy Concerns: Fully isolated and secure execution
-
-2023-03-04
-Custom prompt prefixing and labeling is now supported through the official API. This nets some interesting results when you need ChatGPT for specific uses or entertainment. Select 'CustomGPT' in the model menu to configure this, and you can choose to save the configuration or reference it by conversation. Model selection will change by conversation.
-
-
-2023-03-01
-Official ChatGPT API is out! Removed davinci since the official API is extremely fast and 10x less expensive. Since user labeling and prompt prefixing is officially supported, I will add a View feature so you can set this within chat, which gives the UI an added use case. I've kept the BrowserClient, since it's free to use like the official site.
+- 🔦 **Agents & Tools Integration**:
+ - **[LibreChat Agents](https://www.librechat.ai/docs/features/agents)**:
+ - No-Code Custom Assistants: Build specialized, AI-driven helpers
+ - Agent Marketplace: Discover and deploy community-built agents
+ - Collaborative Sharing: Share agents with specific users and groups
+ - Flexible & Extensible: Use MCP Servers, tools, file search, code execution, and more
+ - Compatible with Custom Endpoints, OpenAI, Azure, Anthropic, AWS Bedrock, Google, Vertex AI, Responses API, and more
+ - [Model Context Protocol (MCP) Support](https://modelcontextprotocol.io/clients#librechat) for Tools
-The Messages UI correctly mirrors code syntax highlighting. The exact replication of the cursor is not 1-to-1 yet, but pretty close. Later on in the project, I'll implement tests for code edge cases and explore the possibility of running code in-browser. Right now, unknown code defaults to javascript, but will detect language as close as possible.
-
-
-2023-02-21
-BingAI is integrated (although sadly limited by Microsoft with the 5 msg/convo limit, 50 msgs/day). I will need to handle the case when Bing refuses to give more answers on top of the other styling features I have in mind. Official ChatGPT use is back with the new BrowserClient. Brainstorming how to handle the UI when the Ai model changes, since conversations can't be persisted between them (or perhaps build a way to achieve this at some level).
-
-
-2023-02-15
-Just got access to Bing AI so I'll be focusing on integrating that through waylaidwanderer's 'experimental' BingAIClient.
-
-
-2023-02-14
+- 🔍 **Web Search**:
+ - Search the internet and retrieve relevant information to enhance your AI context
+ - Combines search providers, content scrapers, and result rerankers for optimal results
+ - **Customizable Jina Reranking**: Configure custom Jina API URLs for reranking services
+ - **[Learn More →](https://www.librechat.ai/docs/features/web_search)**
-Official ChatGPT use is no longer possible though I recently used it with waylaidwanderer's [reverse proxy method](https://github.com/waylaidwanderer/node-chatgpt-api/blob/main/README.md#using-a-reverse-proxy), and before that, through leaked models he also discovered.
+- 🪄 **Generative UI with Code Artifacts**:
+ - [Code Artifacts](https://youtu.be/GfTj7O4gmd0?si=WJbdnemZpJzBrJo3) allow creation of React, HTML, and Mermaid diagrams directly in chat
-Currently, this project is only functional with the `text-davinci-003` model.
-
-
+- 🎨 **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
-# Table of Contents
- * [Roadmap](#roadmap)
- * [Features](#features)
- * [Tech Stack](#tech-stack)
- * [Getting Started](#getting-started)
- * [Prerequisites](#prerequisites)
- * [Usage](#usage)
- * [Local (npm)](#npm)
- * [Docker](#docker)
- * [Access Tokens](#access-tokens)
- * [Updating](#updating)
- * [Use Cases](#use-cases)
- * [Origin](#origin)
- * [Caveats](#caveats)
- * [Contributing](#contributing)
- * [License](#license)
+- 💾 **Presets & Context Management**:
+ - Create, Save, & Share Custom Presets
+ - Switch between AI Endpoints and Presets mid-chat
+ - Edit, Resubmit, and Continue Messages with Conversation branching
+ - Create and share prompts with specific users and groups
+ - [Fork Messages & Conversations](https://www.librechat.ai/docs/features/fork) for Advanced Context control
-## Roadmap
+- 💬 **Multimodal & File Interactions**:
+ - Upload and analyze images with Claude 3, GPT-4.5, GPT-4o, o1, Llama-Vision, and Gemini 📸
+ - Chat with Files using Custom Endpoints, OpenAI, Azure, Anthropic, AWS Bedrock, & Google 🗃️
-> **Warning**
+- 🌎 **Multilingual UI**:
+ - English, 中文 (简体), 中文 (繁體), العربية, Deutsch, Español, Français, Italiano
+ - Polski, Português (PT), Português (BR), Русский, 日本語, Svenska, 한국어, Tiếng Việt
+ - Türkçe, Nederlands, עברית, Català, Čeština, Dansk, Eesti, فارسی
+ - Suomi, Magyar, Հայերեն, Bahasa Indonesia, ქართული, Latviešu, ไทย, ئۇيغۇرچە
-> This is a work in progress. I'm building this in public. FYI there is still a lot of tech debt to cleanup. You can follow the progress here or on my [Linkedin](https://www.linkedin.com/in/danny-avila).
+- 🧠 **Reasoning UI**:
+ - Dynamic Reasoning UI for Chain-of-Thought/Reasoning AI models like DeepSeek-R1
-Here are my recently completed and planned features:
+- 🎨 **Customizable Interface**:
+ - Customizable Dropdown & Interface that adapts to both power users and newcomers
-- [x] Persistent conversation
-- [x] Rename, delete conversations
-- [x] UI Error handling
-- [x] Bing AI integration
-- [x] AI model change handling (start new convos within existing, remembers last selected)
-- [x] Code block handling (highlighting, markdown, clipboard, language detection)
-- [x] Markdown handling
-- [x] Customize prompt prefix/label (custom ChatGPT using official API)
-- [x] Server convo pagination (limit fetch and load more with 'show more' button)
-- [x] Config file for easy startup (docker compose)
-- [ ] Bing AI Styling (for suggested responses, convo end, etc.) - **In progress**
-- [ ] Add warning before clearing convos
-- [ ] Build test suite for CI/CD
-- [ ] Conversation Search (by title)
-- [ ] Resubmit/edit sent messages
-- [ ] Semantic Search Option (requires more tokens)
-- [ ] Prompt Templates/Search
-- [ ] Refactor/clean up code (tech debt)
-- [ ] Optional use of local storage for credentials
-- [ ] Mobile styling (half-finished)
-- [ ] Deploy demo
+- 🗣️ **Speech & Audio**:
+ - Chat hands-free with Speech-to-Text and Text-to-Speech
+ - Automatically send and play Audio
+ - Supports OpenAI, Azure OpenAI, and Elevenlabs
-### Features
+- 📥 **Import & Export Conversations**:
+ - Import Conversations from LibreChat, ChatGPT, Chatbot UI
+ - Export conversations as screenshots, markdown, text, json
-- Response streaming identical to ChatGPT through server-sent events
-- UI from original ChatGPT, including Dark mode
-- AI model selection (official ChatGPT API, BingAI, ChatGPT Free)
-- Create and Save custom ChatGPTs*
+- 🔍 **Search & Discovery**:
+ - Search all messages/conversations
-^* ChatGPT can be 'customized' by setting a system message or prompt prefix and alternate 'role' to the API request
+- 👥 **Multi-User & Secure Access**:
+ - Multi-User, Secure Authentication with OAuth2, LDAP, & Email Login Support
+ - Built-in Moderation, and Token spend tools
-[More info here](https://platform.openai.com/docs/guides/chat/instructing-chat-models). Here's an [example from this app.]()
+- ⚙️ **Configuration & Deployment**:
+ - Configure Proxy, Reverse Proxy, Docker, & many Deployment options
+ - Use completely local or deploy on the cloud
-### Tech Stack
+- 📖 **Open-Source & Community**:
+ - Completely Open-Source & Built in Public
+ - Community-driven development, support, and feedback
-- Utilizes [node-chatgpt-api](https://github.com/waylaidwanderer/node-chatgpt-api)
-- No React boilerplate/toolchain/clone tutorials, created from scratch with react@latest
-- Use of Tailwind CSS and [shadcn/ui](https://github.com/shadcn/ui) components
-- Docker, useSWR, Redux, Express, MongoDB, [Keyv](https://www.npmjs.com/package/keyv)
+[For a thorough review of our features, see our docs here](https://docs.librechat.ai/) 📚
-## Getting Started
+## 🪶 All-In-One AI Conversations with LibreChat
-### Prerequisites
-- npm
-- Node.js >= 19.0.0
-- MongoDB installed or [MongoDB Atlas](https://account.mongodb.com/account/login) (required if not using Docker)
-- [Docker (optional)](https://www.docker.com/get-started/)
-- [OpenAI API key](https://platform.openai.com/account/api-keys)
-- BingAI, ChatGPT access tokens (optional, free AIs)
+LibreChat brings together the future of assistant AIs with the revolutionary technology of OpenAI's ChatGPT. Celebrating the original styling, LibreChat gives you the ability to integrate multiple AI models. It also integrates and enhances original client features such as conversation and message search, prompt templates and plugins.
-## Usage
+With LibreChat, you no longer need to opt for ChatGPT Plus and can instead use free or pay-per-call APIs. We welcome contributions, cloning, and forking to enhance the capabilities of this advanced chatbot platform.
-- **Clone/download** the repo down where desired
-```bash
- git clone https://github.com/danny-avila/chatgpt-clone.git
-```
-- If using MongoDB Atlas, remove `&w=majority` from default connection string.
+[](https://www.youtube.com/watch?v=ilfwGQtJNlI)
-### Local
-- **Run npm** install in both the api and client directories
-- **Provide** all credentials, (API keys, access tokens, and Mongo Connection String) in api/.env [(see .env example)](api/.env.example)
-- **Run** `npm run build` in /client/ dir, `npm start` in /api/ dir
-- **Visit** http://localhost:3080 (default port) & enjoy
+Click on the thumbnail to open the video☝️
-### Docker
+---
-- **Provide** all credentials, (API keys, access tokens, and Mongo Connection String) in [docker-compose.yml](docker-compose.yml) under api service
-- **Build images** in both /api/ and /client/ directories (will eventually share through docker hub)
- - `api/`
- ```bash
- docker build -t node-api .
- ```
- - `client/`
- ```bash
- docker build -t react-client .
- ```
-- **Run** `docker-compose build` in project root dir and then `docker-compose up` to start the app
+## 🌐 Resources
-### Access Tokens
+**GitHub Repo:**
+ - **RAG API:** [github.com/danny-avila/rag_api](https://github.com/danny-avila/rag_api)
+ - **Website:** [github.com/LibreChat-AI/librechat.ai](https://github.com/LibreChat-AI/librechat.ai)
-
-ChatGPT Free Instructions
+**Other:**
+ - **Website:** [librechat.ai](https://librechat.ai)
+ - **Documentation:** [librechat.ai/docs](https://librechat.ai/docs)
+ - **Blog:** [librechat.ai/blog](https://librechat.ai/blog)
+---
-**This has been disabled as is no longer working as of 3-07-23**
+## 📝 Changelog
+Keep up with the latest updates by visiting the releases page and notes:
+- [Releases](https://github.com/danny-avila/LibreChat/releases)
+- [Changelog](https://www.librechat.ai/changelog)
-To get your Access token For ChatGPT 'Free Version', login to chat.openai.com, then visit https://chat.openai.com/api/auth/session.
+**⚠️ Please consult the [changelog](https://www.librechat.ai/changelog) for breaking changes before updating.**
+---
-**Warning:** There may be a high chance of your account being banned with this method. Continue doing so at your own risk.
+## ⭐ Star History
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-BingAI Instructions
-The Bing Access Token is the "_U" cookie from bing.com. Use dev tools or an extension while logged into the site to view it.
+---
-**Note:** Specific error handling and styling for this model is still in progress.
-
+## ✨ Contributions
-### Updating
-- As the project is still a work-in-progress, you should pull the latest and run the steps over. Reset your browser cache/clear site data.
+Contributions, suggestions, bug reports and fixes are welcome!
-## Use Cases ##
+For new features, components, or extensions, please open an issue and discuss before sending a PR.
- - One stop shop for all conversational AIs, with the added bonus of searching past conversations.
- - Using the official API, you'd have to generate 7.5 million words to expense the same cost as ChatGPT Plus ($20).
- - ChatGPT/Google Bard/Bing AI conversations are lost in space or
- cannot be searched past a certain timeframe.
- - **Customize ChatGPT**
+If you'd like to help translate LibreChat into your language, we'd love your contribution! Improving our translations not only makes LibreChat more accessible to users around the world but also enhances the overall user experience. Please check out our [Translation Guide](https://www.librechat.ai/docs/translation).
- 
+---
- - **API is not as limited as ChatGPT Free (at [chat.openai.com](https://chat.openai.com/chat))**
+## 💖 This project exists in its current state thanks to all the people who contribute
- 
+
+
+
- - **ChatGPT Free is down.**
+---
- 
+## 🎉 Special Thanks
+We thank [Locize](https://locize.com) for their translation management tools that support multiple languages in LibreChat.
-## Origin ##
- This project was originally created as a Minimum Viable Product (or MVP) for the [@HackReactor](https://github.com/hackreactor/) Bootcamp. It was built with OpenAI response streaming and most of the UI completed in under 20 hours. During the end of that time, I had most of the UI and basic functionality done. This was created without using any boilerplates or templates, including create-react-app and other toolchains. I didn't follow any 'un-official chatgpt' video tutorials, and simply referenced the official site for the UI. The purpose of the exercise was to learn setting up a full stack project from scratch. Please feel free to give feedback, suggestions, or fork the project for your own use.
-
-
-## Caveats
-### Regarding use of Official ChatGPT API
-From [@waylaidwanderer](https://github.com/waylaidwanderer/node-chatgpt-api/blob/main/README.md#caveats):
-
-Since `gpt-3.5-turbo` is ChatGPT's underlying model, I had to do my best to replicate the way the official ChatGPT website uses it.
-This means my implementation or the underlying model may not behave exactly the same in some ways:
-- Conversations are not tied to any user IDs, so if that's important to you, you should implement your own user ID system.
-- ChatGPT's model parameters (temperature, frequency penalty, etc.) are unknown, so I set some defaults that I thought would be reasonable.
-- Conversations are limited to roughly the last 3000 tokens, so earlier messages may be forgotten during longer conversations.
- - This works in a similar way to ChatGPT, except I'm pretty sure they have some additional way of retrieving context from earlier messages when needed (which can probably be achieved with embeddings, but I consider that out-of-scope for now).
-
-## Contributing
-If you'd like to contribute, please create a pull request with a detailed description of your changes.
-
-## License
-This project is licensed under the MIT License.
+
+
+
+
+
diff --git a/api/.dockerignore b/api/.dockerignore
deleted file mode 100644
index 7af7f04757..0000000000
--- a/api/.dockerignore
+++ /dev/null
@@ -1,2 +0,0 @@
-/node_modules
-.env
\ No newline at end of file
diff --git a/api/.env.example b/api/.env.example
deleted file mode 100644
index 47576c119a..0000000000
--- a/api/.env.example
+++ /dev/null
@@ -1,7 +0,0 @@
-OPENAI_KEY=
-PORT=3080
-NODE_ENV=development
-# Change this to your MongoDB URI if different and I recommend appending chatgpt-clone
-MONGO_URI="mongodb://127.0.0.1:27017/chatgpt-clone"
-CHATGPT_TOKEN=""
-BING_TOKEN=""
\ No newline at end of file
diff --git a/api/Dockerfile b/api/Dockerfile
deleted file mode 100644
index fd21cf4d1a..0000000000
--- a/api/Dockerfile
+++ /dev/null
@@ -1,14 +0,0 @@
-FROM node:19-alpine
-WORKDIR /api
-# copy package.json into the container at /api
-COPY package*.json /api/
-# install dependencies
-RUN npm install
-# Copy the current directory contents into the container at /api
-COPY . /api/
-# Make port 3080 available to the world outside this container
-EXPOSE 3080
-# Run the app when the container launches
-CMD ["npm", "start"]
-
-# docker build -t node-api .
\ No newline at end of file
diff --git a/api/app/bingai.js b/api/app/bingai.js
deleted file mode 100644
index 4dfa8a71d5..0000000000
--- a/api/app/bingai.js
+++ /dev/null
@@ -1,55 +0,0 @@
-require('dotenv').config();
-const { KeyvFile } = require('keyv-file');
-
-const askBing = async ({ text, progressCallback, convo }) => {
- const { BingAIClient } = (await import('@waylaidwanderer/chatgpt-api'));
-
- const bingAIClient = new BingAIClient({
- // "_U" cookie from bing.com
- userToken: process.env.BING_TOKEN,
- // If the above doesn't work, provide all your cookies as a string instead
- // cookies: '',
- debug: false,
- cache: { store: new KeyvFile({ filename: './data/cache.json' }) }
- });
-
- let options = {
- onProgress: async (partialRes) => await progressCallback(partialRes),
- };
-
- if (convo) {
- options = { ...options, ...convo };
- }
-
- const res = await bingAIClient.sendMessage(text, options
- );
-
- return res;
-
- // Example response for reference
- // {
- // conversationSignature: 'wwZ2GC/qRgEqP3VSNIhbPGwtno5RcuBhzZFASOM+Sxg=',
- // conversationId: '51D|BingProd|026D3A4017554DE6C446798144B6337F4D47D5B76E62A31F31D0B1D0A95ED868',
- // clientId: '914800201536527',
- // invocationId: 1,
- // conversationExpiryTime: '2023-02-15T21:48:46.2892088Z',
- // response: 'Hello, this is Bing. Nice to meet you. 😊',
- // details: {
- // text: 'Hello, this is Bing. Nice to meet you. 😊',
- // author: 'bot',
- // createdAt: '2023-02-15T15:48:43.0631898+00:00',
- // timestamp: '2023-02-15T15:48:43.0631898+00:00',
- // messageId: '9d0c9a80-91b1-49ab-b9b1-b457dc3fe247',
- // requestId: '5b252ef8-4f09-4c08-b6f5-4499d2e12fba',
- // offense: 'None',
- // adaptiveCards: [ [Object] ],
- // sourceAttributions: [],
- // feedback: { tag: null, updatedOn: null, type: 'None' },
- // contentOrigin: 'DeepLeo',
- // privacy: null,
- // suggestedResponses: [ [Object], [Object], [Object] ]
- // }
- // }
-};
-
-module.exports = { askBing };
diff --git a/api/app/chatgpt-browser.js b/api/app/chatgpt-browser.js
deleted file mode 100644
index 442d2a731d..0000000000
--- a/api/app/chatgpt-browser.js
+++ /dev/null
@@ -1,33 +0,0 @@
-require('dotenv').config();
-const { KeyvFile } = require('keyv-file');
-
-const clientOptions = {
- // Warning: This will expose your access token to a third party. Consider the risks before using this.
- reverseProxyUrl: 'https://chatgpt.duti.tech/api/conversation',
- // Access token from https://chat.openai.com/api/auth/session
- accessToken: process.env.CHATGPT_TOKEN,
- // debug: true
-};
-
-const browserClient = async ({ text, progressCallback, convo }) => {
- const { ChatGPTBrowserClient } = await import('@waylaidwanderer/chatgpt-api');
-
- const store = {
- store: new KeyvFile({ filename: './data/cache.json' })
- };
-
- const client = new ChatGPTBrowserClient(clientOptions, store);
-
- let options = {
- onProgress: async (partialRes) => await progressCallback(partialRes)
- };
-
- if (!!convo.parentMessageId && !!convo.conversationId) {
- options = { ...options, ...convo };
- }
-
- const res = await client.sendMessage(text, options);
- return res;
-};
-
-module.exports = { browserClient };
diff --git a/api/app/chatgpt-client.js b/api/app/chatgpt-client.js
deleted file mode 100644
index ce3c0e2271..0000000000
--- a/api/app/chatgpt-client.js
+++ /dev/null
@@ -1,31 +0,0 @@
-require('dotenv').config();
-const { KeyvFile } = require('keyv-file');
-
-const clientOptions = {
- modelOptions: {
- model: 'gpt-3.5-turbo'
- },
- debug: false
-};
-
-const askClient = async ({ text, progressCallback, convo }) => {
- const ChatGPTClient = (await import('@waylaidwanderer/chatgpt-api')).default;
- const store = {
- store: new KeyvFile({ filename: './data/cache.json' })
- };
-
- const client = new ChatGPTClient(process.env.OPENAI_KEY, clientOptions, store);
-
- let options = {
- onProgress: async (partialRes) => await progressCallback(partialRes)
- };
-
- if (!!convo.parentMessageId && !!convo.conversationId) {
- options = { ...options, ...convo };
- }
-
- const res = await client.sendMessage(text, options);
- return res;
-};
-
-module.exports = { askClient };
diff --git a/api/app/chatgpt-custom.js b/api/app/chatgpt-custom.js
deleted file mode 100644
index d31901c75f..0000000000
--- a/api/app/chatgpt-custom.js
+++ /dev/null
@@ -1,37 +0,0 @@
-require('dotenv').config();
-const { KeyvFile } = require('keyv-file');
-
-const clientOptions = {
- modelOptions: {
- model: 'gpt-3.5-turbo'
- },
- debug: false
-};
-
-const customClient = async ({ text, progressCallback, convo, promptPrefix, chatGptLabel }) => {
- const ChatGPTClient = (await import('@waylaidwanderer/chatgpt-api')).default;
- const store = {
- store: new KeyvFile({ filename: './data/cache.json' })
- };
-
- clientOptions.chatGptLabel = chatGptLabel;
-
- if (promptPrefix.length > 0) {
- clientOptions.promptPrefix = promptPrefix;
- }
-
- const client = new ChatGPTClient(process.env.OPENAI_KEY, clientOptions, store);
-
- let options = {
- onProgress: async (partialRes) => await progressCallback(partialRes)
- };
-
- if (!!convo.parentMessageId && !!convo.conversationId) {
- options = { ...options, ...convo };
- }
-
- const res = await client.sendMessage(text, options);
- return res;
-};
-
-module.exports = customClient;
diff --git a/api/app/chatgpt.js b/api/app/chatgpt.js
deleted file mode 100644
index 18edcfca83..0000000000
--- a/api/app/chatgpt.js
+++ /dev/null
@@ -1,38 +0,0 @@
-require('dotenv').config();
-const Keyv = require('keyv');
-const { Configuration, OpenAIApi } = require('openai');
-const messageStore = new Keyv(process.env.MONGODB_URI, { namespace: 'chatgpt' });
-
-const ask = async (question, progressCallback, convo) => {
- const { ChatGPTAPI } = await import('chatgpt');
- const api = new ChatGPTAPI({ apiKey: process.env.OPENAI_KEY, messageStore });
- let options = {
- onProgress: async (partialRes) => {
- if (partialRes.text.length > 0) {
- await progressCallback(partialRes);
- }
- }
- };
-
- if (!!convo.parentMessageId && !!convo.conversationId) {
- options = { ...options, ...convo };
- }
-
- const res = await api.sendMessage(question, options);
- return res;
-};
-
-const titleConvo = async (message, response, model) => {
- const configuration = new Configuration({
- apiKey: process.env.OPENAI_KEY
- });
- const openai = new OpenAIApi(configuration);
- const completion = await openai.createCompletion({
- model: 'text-davinci-002',
- prompt: `Write a short title in title case, ideally in 5 words or less, and do not refer to the user or ${model}, that summarizes this conversation:\nUser:"${message}"\n${model}:"${response}"\nTitle: `
- });
-
- return completion.data.choices[0].text.replace(/\n/g, '');
-};
-
-module.exports = { ask, titleConvo };
diff --git a/api/app/citeText.js b/api/app/citeText.js
deleted file mode 100644
index 536c981d4e..0000000000
--- a/api/app/citeText.js
+++ /dev/null
@@ -1,29 +0,0 @@
-const citationRegex = /\[\^\d+?\^]/g;
-
-const citeText = (res, noLinks = false) => {
- let result = res.text || res;
- const citations = Array.from(new Set(result.match(citationRegex)));
- if (citations?.length === 0) return result;
-
- if (noLinks) {
- citations.forEach((citation) => {
- const digit = citation.match(/\d+?/g)[0];
- result = result.replaceAll(citation, `[${digit}](#) `);
- });
-
- return result;
- }
-
- let sources = res.details.sourceAttributions;
- if (sources?.length === 0) return result;
- sources = sources.map((source) => source.seeMoreUrl);
-
- citations.forEach((citation) => {
- const digit = citation.match(/\d+?/g)[0];
- result = result.replaceAll(citation, `[${digit}](${sources[digit - 1]}) `);
- });
-
- return result;
-};
-
-module.exports = citeText;
diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js
new file mode 100644
index 0000000000..e85a550e26
--- /dev/null
+++ b/api/app/clients/BaseClient.js
@@ -0,0 +1,1433 @@
+const crypto = require('crypto');
+const fetch = require('node-fetch');
+const { logger } = require('@librechat/data-schemas');
+const {
+ countTokens,
+ getBalanceConfig,
+ extractFileContext,
+ encodeAndFormatAudios,
+ encodeAndFormatVideos,
+ encodeAndFormatDocuments,
+} = require('@librechat/api');
+const {
+ Constants,
+ ErrorTypes,
+ FileSources,
+ ContentTypes,
+ excludedKeys,
+ EModelEndpoint,
+ isParamEndpoint,
+ isAgentsEndpoint,
+ supportsBalanceCheck,
+} = require('librechat-data-provider');
+const {
+ updateMessage,
+ getMessages,
+ saveMessage,
+ saveConvo,
+ getConvo,
+ getFiles,
+} = require('~/models');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+const { checkBalance } = require('~/models/balanceMethods');
+const { truncateToolCallOutputs } = require('./prompts');
+const TextStream = require('./TextStream');
+
+class BaseClient {
+ constructor(apiKey, options = {}) {
+ this.apiKey = apiKey;
+ this.sender = options.sender ?? 'AI';
+ this.contextStrategy = null;
+ this.currentDateString = new Date().toLocaleDateString('en-us', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ });
+ /** @type {boolean} */
+ this.skipSaveConvo = false;
+ /** @type {boolean} */
+ this.skipSaveUserMessage = false;
+ /** @type {string} */
+ this.user;
+ /** @type {string} */
+ this.conversationId;
+ /** @type {string} */
+ this.responseMessageId;
+ /** @type {string} */
+ this.parentMessageId;
+ /** @type {TAttachment[]} */
+ this.attachments;
+ /** The key for the usage object's input tokens
+ * @type {string} */
+ this.inputTokensKey = 'prompt_tokens';
+ /** The key for the usage object's output tokens
+ * @type {string} */
+ this.outputTokensKey = 'completion_tokens';
+ /** @type {Set} */
+ this.savedMessageIds = new Set();
+ /**
+ * Flag to determine if the client re-submitted the latest assistant message.
+ * @type {boolean | undefined} */
+ this.continued;
+ /**
+ * Flag to determine if the client has already fetched the conversation while saving new messages.
+ * @type {boolean | undefined} */
+ this.fetchedConvo;
+ /** @type {TMessage[]} */
+ this.currentMessages = [];
+ /** @type {import('librechat-data-provider').VisionModes | undefined} */
+ this.visionMode;
+ }
+
+ setOptions() {
+ throw new Error("Method 'setOptions' must be implemented.");
+ }
+
+ async getCompletion() {
+ throw new Error("Method 'getCompletion' must be implemented.");
+ }
+
+ /** @type {sendCompletion} */
+ async sendCompletion() {
+ throw new Error("Method 'sendCompletion' must be implemented.");
+ }
+
+ getSaveOptions() {
+ throw new Error('Subclasses must implement getSaveOptions');
+ }
+
+ async buildMessages() {
+ throw new Error('Subclasses must implement buildMessages');
+ }
+
+ async summarizeMessages() {
+ throw new Error('Subclasses attempted to call summarizeMessages without implementing it');
+ }
+
+ /**
+ * @returns {string}
+ */
+ getResponseModel() {
+ if (isAgentsEndpoint(this.options.endpoint) && this.options.agent && this.options.agent.id) {
+ return this.options.agent.id;
+ }
+
+ return this.modelOptions?.model ?? this.model;
+ }
+
+ /**
+ * Abstract method to get the token count for a message. Subclasses must implement this method.
+ * @param {TMessage} responseMessage
+ * @returns {number}
+ */
+ getTokenCountForResponse(responseMessage) {
+ logger.debug('[BaseClient] `recordTokenUsage` not implemented.', responseMessage);
+ }
+
+ /**
+ * Abstract method to record token usage. Subclasses must implement this method.
+ * If a correction to the token usage is needed, the method should return an object with the corrected token counts.
+ * Should only be used if `recordCollectedUsage` was not used instead.
+ * @param {string} [model]
+ * @param {AppConfig['balance']} [balance]
+ * @param {number} promptTokens
+ * @param {number} completionTokens
+ * @returns {Promise}
+ */
+ async recordTokenUsage({ model, balance, promptTokens, completionTokens }) {
+ logger.debug('[BaseClient] `recordTokenUsage` not implemented.', {
+ model,
+ balance,
+ promptTokens,
+ completionTokens,
+ });
+ }
+
+ /**
+ * 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} - A promise that resolves to the response of the fetch request.
+ */
+ async fetch(_url, init) {
+ let url = _url;
+ if (this.options.directEndpoint) {
+ url = this.options.reverseProxyUrl;
+ }
+ logger.debug(`Making request to ${url}`);
+ if (typeof Bun !== 'undefined') {
+ return await fetch(url, init);
+ }
+ return await fetch(url, init);
+ }
+
+ getBuildMessagesOptions() {
+ throw new Error('Subclasses must implement getBuildMessagesOptions');
+ }
+
+ async generateTextStream(text, onProgress, options = {}) {
+ const stream = new TextStream(text, options);
+ await stream.processTextStream(onProgress);
+ }
+
+ /**
+ * @returns {[string|undefined, string|undefined]}
+ */
+ processOverideIds() {
+ /** @type {Record} */
+ let { overrideConvoId, overrideUserMessageId } = this.options?.req?.body ?? {};
+ if (overrideConvoId) {
+ const [conversationId, index] = overrideConvoId.split(Constants.COMMON_DIVIDER);
+ overrideConvoId = conversationId;
+ if (index !== '0') {
+ this.skipSaveConvo = true;
+ }
+ }
+ if (overrideUserMessageId) {
+ const [userMessageId, index] = overrideUserMessageId.split(Constants.COMMON_DIVIDER);
+ overrideUserMessageId = userMessageId;
+ if (index !== '0') {
+ this.skipSaveUserMessage = true;
+ }
+ }
+
+ return [overrideConvoId, overrideUserMessageId];
+ }
+
+ async setMessageOptions(opts = {}) {
+ if (opts && opts.replaceOptions) {
+ this.setOptions(opts);
+ }
+
+ const [overrideConvoId, overrideUserMessageId] = this.processOverideIds();
+ const { isEdited, isContinued } = opts;
+ const user = opts.user ?? null;
+ this.user = user;
+ const saveOptions = this.getSaveOptions();
+ this.abortController = opts.abortController ?? new AbortController();
+ const requestConvoId = overrideConvoId ?? opts.conversationId;
+ const conversationId = requestConvoId ?? crypto.randomUUID();
+ const parentMessageId = opts.parentMessageId ?? Constants.NO_PARENT;
+ const userMessageId =
+ overrideUserMessageId ?? opts.overrideParentMessageId ?? crypto.randomUUID();
+ let responseMessageId = opts.responseMessageId ?? crypto.randomUUID();
+ let head = isEdited ? responseMessageId : parentMessageId;
+ this.currentMessages = (await this.loadHistory(conversationId, head)) ?? [];
+ this.conversationId = conversationId;
+
+ if (isEdited && !isContinued) {
+ responseMessageId = crypto.randomUUID();
+ head = responseMessageId;
+ this.currentMessages[this.currentMessages.length - 1].messageId = head;
+ }
+
+ if (opts.isRegenerate && responseMessageId.endsWith('_')) {
+ responseMessageId = crypto.randomUUID();
+ }
+
+ this.responseMessageId = responseMessageId;
+
+ return {
+ ...opts,
+ user,
+ head,
+ saveOptions,
+ userMessageId,
+ requestConvoId,
+ conversationId,
+ parentMessageId,
+ responseMessageId,
+ };
+ }
+
+ createUserMessage({ messageId, parentMessageId, conversationId, text }) {
+ return {
+ messageId,
+ parentMessageId,
+ conversationId,
+ sender: 'User',
+ text,
+ isCreatedByUser: true,
+ };
+ }
+
+ async handleStartMethods(message, opts) {
+ const {
+ user,
+ head,
+ saveOptions,
+ userMessageId,
+ requestConvoId,
+ conversationId,
+ parentMessageId,
+ responseMessageId,
+ } = await this.setMessageOptions(opts);
+
+ const userMessage = opts.isEdited
+ ? this.currentMessages[this.currentMessages.length - 2]
+ : this.createUserMessage({
+ messageId: userMessageId,
+ parentMessageId,
+ conversationId,
+ text: message,
+ });
+
+ if (typeof opts?.getReqData === 'function') {
+ opts.getReqData({
+ userMessage,
+ conversationId,
+ responseMessageId,
+ sender: this.sender,
+ });
+ }
+
+ if (typeof opts?.onStart === 'function') {
+ const isNewConvo = !requestConvoId && parentMessageId === Constants.NO_PARENT;
+ opts.onStart(userMessage, responseMessageId, isNewConvo);
+ }
+
+ return {
+ ...opts,
+ user,
+ head,
+ conversationId,
+ responseMessageId,
+ saveOptions,
+ userMessage,
+ };
+ }
+
+ /**
+ * Adds instructions to the messages array. If the instructions object is empty or undefined,
+ * the original messages array is returned. Otherwise, the instructions are added to the messages
+ * array either at the beginning (default) or preserving the last message at the end.
+ *
+ * @param {Array} messages - An array of messages.
+ * @param {Object} instructions - An object containing instructions to be added to the messages.
+ * @param {boolean} [beforeLast=false] - If true, adds instructions before the last message; if false, adds at the beginning.
+ * @returns {Array} An array containing messages and instructions, or the original messages if instructions are empty.
+ */
+ addInstructions(messages, instructions, beforeLast = false) {
+ if (!instructions || Object.keys(instructions).length === 0) {
+ return messages;
+ }
+
+ if (!beforeLast) {
+ return [instructions, ...messages];
+ }
+
+ // Legacy behavior: add instructions before the last message
+ const payload = [];
+ if (messages.length > 1) {
+ payload.push(...messages.slice(0, -1));
+ }
+
+ payload.push(instructions);
+
+ if (messages.length > 0) {
+ payload.push(messages[messages.length - 1]);
+ }
+
+ return payload;
+ }
+
+ async handleTokenCountMap(tokenCountMap) {
+ if (this.clientName === EModelEndpoint.agents) {
+ return;
+ }
+ if (this.currentMessages.length === 0) {
+ return;
+ }
+
+ for (let i = 0; i < this.currentMessages.length; i++) {
+ // Skip the last message, which is the user message.
+ if (i === this.currentMessages.length - 1) {
+ break;
+ }
+
+ const message = this.currentMessages[i];
+ const { messageId } = message;
+ const update = {};
+
+ if (messageId === tokenCountMap.summaryMessage?.messageId) {
+ logger.debug(`[BaseClient] Adding summary props to ${messageId}.`);
+
+ update.summary = tokenCountMap.summaryMessage.content;
+ update.summaryTokenCount = tokenCountMap.summaryMessage.tokenCount;
+ }
+
+ if (message.tokenCount && !update.summaryTokenCount) {
+ logger.debug(`[BaseClient] Skipping ${messageId}: already had a token count.`);
+ continue;
+ }
+
+ const tokenCount = tokenCountMap[messageId];
+ if (tokenCount) {
+ message.tokenCount = tokenCount;
+ update.tokenCount = tokenCount;
+ await this.updateMessageInDatabase({ messageId, ...update });
+ }
+ }
+ }
+
+ concatenateMessages(messages) {
+ return messages.reduce((acc, message) => {
+ const nameOrRole = message.name ?? message.role;
+ return acc + `${nameOrRole}:\n${message.content}\n\n`;
+ }, '');
+ }
+
+ /**
+ * This method processes an array of messages and returns a context of messages that fit within a specified token limit.
+ * It iterates over the messages from newest to oldest, adding them to the context until the token limit is reached.
+ * If the token limit would be exceeded by adding a message, that message is not added to the context and remains in the original array.
+ * The method uses `push` and `pop` operations for efficient array manipulation, and reverses the context array at the end to maintain the original order of the messages.
+ *
+ * @param {Object} params
+ * @param {TMessage[]} params.messages - An array of messages, each with a `tokenCount` property. The messages should be ordered from oldest to newest.
+ * @param {number} [params.maxContextTokens] - The max number of tokens allowed in the context. If not provided, defaults to `this.maxContextTokens`.
+ * @param {{ role: 'system', content: text, tokenCount: number }} [params.instructions] - Instructions already added to the context at index 0.
+ * @returns {Promise<{
+ * context: TMessage[],
+ * remainingContextTokens: number,
+ * messagesToRefine: TMessage[],
+ * }>} An object with three properties: `context`, `remainingContextTokens`, and `messagesToRefine`.
+ * `context` is an array of messages that fit within the token limit.
+ * `remainingContextTokens` is the number of tokens remaining within the limit after adding the messages to the context.
+ * `messagesToRefine` is an array of messages that were not added to the context because they would have exceeded the token limit.
+ */
+ async getMessagesWithinTokenLimit({ messages: _messages, maxContextTokens, instructions }) {
+ // Every reply is primed with <|start|>assistant<|message|>, so we
+ // start with 3 tokens for the label after all messages have been counted.
+ let currentTokenCount = 3;
+ const instructionsTokenCount = instructions?.tokenCount ?? 0;
+ let remainingContextTokens =
+ (maxContextTokens ?? this.maxContextTokens) - instructionsTokenCount;
+ const messages = [..._messages];
+
+ const context = [];
+
+ if (currentTokenCount < remainingContextTokens) {
+ while (messages.length > 0 && currentTokenCount < remainingContextTokens) {
+ if (messages.length === 1 && instructions) {
+ break;
+ }
+ const poppedMessage = messages.pop();
+ const { tokenCount } = poppedMessage;
+
+ if (poppedMessage && currentTokenCount + tokenCount <= remainingContextTokens) {
+ context.push(poppedMessage);
+ currentTokenCount += tokenCount;
+ } else {
+ messages.push(poppedMessage);
+ break;
+ }
+ }
+ }
+
+ if (instructions) {
+ context.push(_messages[0]);
+ messages.shift();
+ }
+
+ const prunedMemory = messages;
+ remainingContextTokens -= currentTokenCount;
+
+ return {
+ context: context.reverse(),
+ remainingContextTokens,
+ messagesToRefine: prunedMemory,
+ };
+ }
+
+ async handleContextStrategy({
+ instructions,
+ orderedMessages,
+ formattedMessages,
+ buildTokenMap = true,
+ }) {
+ let _instructions;
+ let tokenCount;
+
+ if (instructions) {
+ ({ tokenCount, ..._instructions } = instructions);
+ }
+
+ _instructions && logger.debug('[BaseClient] instructions tokenCount: ' + tokenCount);
+ if (tokenCount && tokenCount > this.maxContextTokens) {
+ const info = `${tokenCount} / ${this.maxContextTokens}`;
+ const errorMessage = `{ "type": "${ErrorTypes.INPUT_LENGTH}", "info": "${info}" }`;
+ logger.warn(`Instructions token count exceeds max token count (${info}).`);
+ throw new Error(errorMessage);
+ }
+
+ if (this.clientName === EModelEndpoint.agents) {
+ const { dbMessages, editedIndices } = truncateToolCallOutputs(
+ orderedMessages,
+ this.maxContextTokens,
+ this.getTokenCountForMessage.bind(this),
+ );
+
+ if (editedIndices.length > 0) {
+ logger.debug('[BaseClient] Truncated tool call outputs:', editedIndices);
+ for (const index of editedIndices) {
+ formattedMessages[index].content = dbMessages[index].content;
+ }
+ orderedMessages = dbMessages;
+ }
+ }
+
+ let orderedWithInstructions = this.addInstructions(orderedMessages, instructions);
+
+ let { context, remainingContextTokens, messagesToRefine } =
+ await this.getMessagesWithinTokenLimit({
+ messages: orderedWithInstructions,
+ instructions,
+ });
+
+ logger.debug('[BaseClient] Context Count (1/2)', {
+ remainingContextTokens,
+ maxContextTokens: this.maxContextTokens,
+ });
+
+ let summaryMessage;
+ let summaryTokenCount;
+ let { shouldSummarize } = this;
+
+ // Calculate the difference in length to determine how many messages were discarded if any
+ let payload;
+ let { length } = formattedMessages;
+ length += instructions != null ? 1 : 0;
+ const diff = length - context.length;
+ const firstMessage = orderedWithInstructions[0];
+ const usePrevSummary =
+ shouldSummarize &&
+ diff === 1 &&
+ firstMessage?.summary &&
+ this.previous_summary.messageId === firstMessage.messageId;
+
+ if (diff > 0) {
+ payload = formattedMessages.slice(diff);
+ logger.debug(
+ `[BaseClient] Difference between original payload (${length}) and context (${context.length}): ${diff}`,
+ );
+ }
+
+ payload = this.addInstructions(payload ?? formattedMessages, _instructions);
+
+ const latestMessage = orderedWithInstructions[orderedWithInstructions.length - 1];
+ if (payload.length === 0 && !shouldSummarize && latestMessage) {
+ const info = `${latestMessage.tokenCount} / ${this.maxContextTokens}`;
+ const errorMessage = `{ "type": "${ErrorTypes.INPUT_LENGTH}", "info": "${info}" }`;
+ logger.warn(`Prompt token count exceeds max token count (${info}).`);
+ throw new Error(errorMessage);
+ } else if (
+ _instructions &&
+ payload.length === 1 &&
+ payload[0].content === _instructions.content
+ ) {
+ const info = `${tokenCount + 3} / ${this.maxContextTokens}`;
+ const errorMessage = `{ "type": "${ErrorTypes.INPUT_LENGTH}", "info": "${info}" }`;
+ logger.warn(
+ `Including instructions, the prompt token count exceeds remaining max token count (${info}).`,
+ );
+ throw new Error(errorMessage);
+ }
+
+ if (usePrevSummary) {
+ summaryMessage = { role: 'system', content: firstMessage.summary };
+ summaryTokenCount = firstMessage.summaryTokenCount;
+ payload.unshift(summaryMessage);
+ remainingContextTokens -= summaryTokenCount;
+ } else if (shouldSummarize && messagesToRefine.length > 0) {
+ ({ summaryMessage, summaryTokenCount } = await this.summarizeMessages({
+ messagesToRefine,
+ remainingContextTokens,
+ }));
+ summaryMessage && payload.unshift(summaryMessage);
+ remainingContextTokens -= summaryTokenCount;
+ }
+
+ // Make sure to only continue summarization logic if the summary message was generated
+ shouldSummarize = summaryMessage != null && shouldSummarize === true;
+
+ logger.debug('[BaseClient] Context Count (2/2)', {
+ remainingContextTokens,
+ maxContextTokens: this.maxContextTokens,
+ });
+
+ /** @type {Record | undefined} */
+ let tokenCountMap;
+ if (buildTokenMap) {
+ const currentPayload = shouldSummarize ? orderedWithInstructions : context;
+ tokenCountMap = currentPayload.reduce((map, message, index) => {
+ const { messageId } = message;
+ if (!messageId) {
+ return map;
+ }
+
+ if (shouldSummarize && index === messagesToRefine.length - 1 && !usePrevSummary) {
+ map.summaryMessage = { ...summaryMessage, messageId, tokenCount: summaryTokenCount };
+ }
+
+ map[messageId] = currentPayload[index].tokenCount;
+ return map;
+ }, {});
+ }
+
+ const promptTokens = this.maxContextTokens - remainingContextTokens;
+
+ logger.debug('[BaseClient] tokenCountMap:', tokenCountMap);
+ logger.debug('[BaseClient]', {
+ promptTokens,
+ remainingContextTokens,
+ payloadSize: payload.length,
+ maxContextTokens: this.maxContextTokens,
+ });
+
+ return { payload, tokenCountMap, promptTokens, messages: orderedWithInstructions };
+ }
+
+ async sendMessage(message, opts = {}) {
+ const appConfig = this.options.req?.config;
+ /** @type {Promise} */
+ let userMessagePromise;
+ const { user, head, isEdited, conversationId, responseMessageId, saveOptions, userMessage } =
+ await this.handleStartMethods(message, opts);
+
+ if (opts.progressCallback) {
+ opts.onProgress = opts.progressCallback.call(null, {
+ ...(opts.progressOptions ?? {}),
+ parentMessageId: userMessage.messageId,
+ messageId: responseMessageId,
+ });
+ }
+
+ const { editedContent } = opts;
+
+ // It's not necessary to push to currentMessages
+ // depending on subclass implementation of handling messages
+ // When this is an edit, all messages are already in currentMessages, both user and response
+ if (isEdited) {
+ let latestMessage = this.currentMessages[this.currentMessages.length - 1];
+ if (!latestMessage) {
+ latestMessage = {
+ messageId: responseMessageId,
+ conversationId,
+ parentMessageId: userMessage.messageId,
+ isCreatedByUser: false,
+ model: this.modelOptions?.model ?? this.model,
+ sender: this.sender,
+ };
+ this.currentMessages.push(userMessage, latestMessage);
+ } else if (editedContent != null) {
+ // Handle editedContent for content parts
+ if (editedContent && latestMessage.content && Array.isArray(latestMessage.content)) {
+ const { index, text, type } = editedContent;
+ if (index >= 0 && index < latestMessage.content.length) {
+ const contentPart = latestMessage.content[index];
+ if (type === ContentTypes.THINK && contentPart.type === ContentTypes.THINK) {
+ contentPart[ContentTypes.THINK] = text;
+ } else if (type === ContentTypes.TEXT && contentPart.type === ContentTypes.TEXT) {
+ contentPart[ContentTypes.TEXT] = text;
+ }
+ }
+ }
+ }
+ this.continued = true;
+ } else {
+ this.currentMessages.push(userMessage);
+ }
+
+ /**
+ * When the userMessage is pushed to currentMessages, the parentMessage is the userMessageId.
+ * this only matters when buildMessages is utilizing the parentMessageId, and may vary on implementation
+ */
+ const parentMessageId = isEdited ? head : userMessage.messageId;
+ this.parentMessageId = parentMessageId;
+ let {
+ prompt: payload,
+ tokenCountMap,
+ promptTokens,
+ } = await this.buildMessages(
+ this.currentMessages,
+ parentMessageId,
+ this.getBuildMessagesOptions(opts),
+ opts,
+ );
+
+ if (tokenCountMap) {
+ logger.debug('[BaseClient] tokenCountMap', tokenCountMap);
+ if (tokenCountMap[userMessage.messageId]) {
+ userMessage.tokenCount = tokenCountMap[userMessage.messageId];
+ logger.debug('[BaseClient] userMessage', userMessage);
+ }
+
+ this.handleTokenCountMap(tokenCountMap);
+ }
+
+ if (!isEdited && !this.skipSaveUserMessage) {
+ userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user);
+ this.savedMessageIds.add(userMessage.messageId);
+ if (typeof opts?.getReqData === 'function') {
+ opts.getReqData({
+ userMessagePromise,
+ });
+ }
+ }
+
+ const balanceConfig = getBalanceConfig(appConfig);
+ if (
+ balanceConfig?.enabled &&
+ supportsBalanceCheck[this.options.endpointType ?? this.options.endpoint]
+ ) {
+ await checkBalance({
+ req: this.options.req,
+ res: this.options.res,
+ txData: {
+ user: this.user,
+ tokenType: 'prompt',
+ amount: promptTokens,
+ endpoint: this.options.endpoint,
+ model: this.modelOptions?.model ?? this.model,
+ endpointTokenConfig: this.options.endpointTokenConfig,
+ },
+ });
+ }
+
+ const { completion, metadata } = await this.sendCompletion(payload, opts);
+ if (this.abortController) {
+ this.abortController.requestCompleted = true;
+ }
+
+ /** @type {TMessage} */
+ const responseMessage = {
+ messageId: responseMessageId,
+ conversationId,
+ parentMessageId: userMessage.messageId,
+ isCreatedByUser: false,
+ isEdited,
+ model: this.getResponseModel(),
+ sender: this.sender,
+ promptTokens,
+ iconURL: this.options.iconURL,
+ endpoint: this.options.endpoint,
+ ...(this.metadata ?? {}),
+ metadata,
+ };
+
+ if (typeof completion === 'string') {
+ responseMessage.text = completion;
+ } else if (
+ Array.isArray(completion) &&
+ (this.clientName === EModelEndpoint.agents ||
+ isParamEndpoint(this.options.endpoint, this.options.endpointType))
+ ) {
+ responseMessage.text = '';
+
+ if (!opts.editedContent || this.currentMessages.length === 0) {
+ responseMessage.content = completion;
+ } else {
+ const latestMessage = this.currentMessages[this.currentMessages.length - 1];
+ if (!latestMessage?.content) {
+ responseMessage.content = completion;
+ } else {
+ const existingContent = [...latestMessage.content];
+ const { type: editedType } = opts.editedContent;
+ responseMessage.content = this.mergeEditedContent(
+ existingContent,
+ completion,
+ editedType,
+ );
+ }
+ }
+ } else if (Array.isArray(completion)) {
+ responseMessage.text = completion.join('');
+ }
+
+ if (
+ tokenCountMap &&
+ this.recordTokenUsage &&
+ this.getTokenCountForResponse &&
+ this.getTokenCount
+ ) {
+ let completionTokens;
+
+ /**
+ * Metadata about input/output costs for the current message. The client
+ * should provide a function to get the current stream usage metadata; if not,
+ * use the legacy token estimations.
+ * @type {StreamUsage | null} */
+ const usage = this.getStreamUsage != null ? this.getStreamUsage() : null;
+
+ if (usage != null && Number(usage[this.outputTokensKey]) > 0) {
+ responseMessage.tokenCount = usage[this.outputTokensKey];
+ completionTokens = responseMessage.tokenCount;
+ await this.updateUserMessageTokenCount({
+ usage,
+ tokenCountMap,
+ userMessage,
+ userMessagePromise,
+ opts,
+ });
+ } else {
+ responseMessage.tokenCount = this.getTokenCountForResponse(responseMessage);
+ completionTokens = responseMessage.tokenCount;
+ await this.recordTokenUsage({
+ usage,
+ promptTokens,
+ completionTokens,
+ balance: balanceConfig,
+ model: responseMessage.model,
+ });
+ }
+ }
+
+ if (userMessagePromise) {
+ await userMessagePromise;
+ }
+
+ if (this.artifactPromises) {
+ responseMessage.attachments = (await Promise.all(this.artifactPromises)).filter((a) => a);
+ }
+
+ if (this.options.attachments) {
+ try {
+ saveOptions.files = this.options.attachments.map((attachments) => attachments.file_id);
+ } catch (error) {
+ logger.error('[BaseClient] Error mapping attachments for conversation', error);
+ }
+ }
+
+ responseMessage.databasePromise = this.saveMessageToDatabase(
+ responseMessage,
+ saveOptions,
+ user,
+ );
+ this.savedMessageIds.add(responseMessage.messageId);
+ delete responseMessage.tokenCount;
+ return responseMessage;
+ }
+
+ /**
+ * Stream usage should only be used for user message token count re-calculation if:
+ * - The stream usage is available, with input tokens greater than 0,
+ * - the client provides a function to calculate the current token count,
+ * - files are being resent with every message (default behavior; or if `false`, with no attachments),
+ * - the `promptPrefix` (custom instructions) is not set.
+ *
+ * In these cases, the legacy token estimations would be more accurate.
+ *
+ * TODO: included system messages in the `orderedMessages` accounting, potentially as a
+ * separate message in the UI. ChatGPT does this through "hidden" system messages.
+ * @param {object} params
+ * @param {StreamUsage} params.usage
+ * @param {Record} params.tokenCountMap
+ * @param {TMessage} params.userMessage
+ * @param {Promise} params.userMessagePromise
+ * @param {object} params.opts
+ */
+ async updateUserMessageTokenCount({
+ usage,
+ tokenCountMap,
+ userMessage,
+ userMessagePromise,
+ opts,
+ }) {
+ /** @type {boolean} */
+ const shouldUpdateCount =
+ this.calculateCurrentTokenCount != null &&
+ Number(usage[this.inputTokensKey]) > 0 &&
+ (this.options.resendFiles ||
+ (!this.options.resendFiles && !this.options.attachments?.length)) &&
+ !this.options.promptPrefix;
+
+ if (!shouldUpdateCount) {
+ return;
+ }
+
+ const userMessageTokenCount = this.calculateCurrentTokenCount({
+ currentMessageId: userMessage.messageId,
+ tokenCountMap,
+ usage,
+ });
+
+ if (userMessageTokenCount === userMessage.tokenCount) {
+ return;
+ }
+
+ userMessage.tokenCount = userMessageTokenCount;
+ /*
+ Note: `AgentController` saves the user message if not saved here
+ (noted by `savedMessageIds`), so we update the count of its `userMessage` reference
+ */
+ if (typeof opts?.getReqData === 'function') {
+ opts.getReqData({
+ userMessage,
+ });
+ }
+ /*
+ Note: we update the user message to be sure it gets the calculated token count;
+ though `AgentController` saves the user message if not saved here
+ (noted by `savedMessageIds`), EditController does not
+ */
+ await userMessagePromise;
+ await this.updateMessageInDatabase({
+ messageId: userMessage.messageId,
+ tokenCount: userMessageTokenCount,
+ });
+ }
+
+ async loadHistory(conversationId, parentMessageId = null) {
+ logger.debug('[BaseClient] Loading history:', { conversationId, parentMessageId });
+
+ const messages = (await getMessages({ conversationId })) ?? [];
+
+ if (messages.length === 0) {
+ return [];
+ }
+
+ let mapMethod = null;
+ if (this.getMessageMapMethod) {
+ mapMethod = this.getMessageMapMethod();
+ }
+
+ let _messages = this.constructor.getMessagesForConversation({
+ messages,
+ parentMessageId,
+ mapMethod,
+ });
+
+ _messages = await this.addPreviousAttachments(_messages);
+
+ if (!this.shouldSummarize) {
+ return _messages;
+ }
+
+ // Find the latest message with a 'summary' property
+ for (let i = _messages.length - 1; i >= 0; i--) {
+ if (_messages[i]?.summary) {
+ this.previous_summary = _messages[i];
+ break;
+ }
+ }
+
+ if (this.previous_summary) {
+ const { messageId, summary, tokenCount, summaryTokenCount } = this.previous_summary;
+ logger.debug('[BaseClient] Previous summary:', {
+ messageId,
+ summary,
+ tokenCount,
+ summaryTokenCount,
+ });
+ }
+
+ return _messages;
+ }
+
+ /**
+ * Save a message to the database.
+ * @param {TMessage} message
+ * @param {Partial} endpointOptions
+ * @param {string | null} user
+ */
+ async saveMessageToDatabase(message, endpointOptions, user = null) {
+ if (this.user && user !== this.user) {
+ throw new Error('User mismatch.');
+ }
+
+ const savedMessage = await saveMessage(
+ this.options?.req,
+ {
+ ...message,
+ endpoint: this.options.endpoint,
+ unfinished: false,
+ user,
+ },
+ { context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveMessage' },
+ );
+
+ if (this.skipSaveConvo) {
+ return { message: savedMessage };
+ }
+
+ const fieldsToKeep = {
+ conversationId: message.conversationId,
+ endpoint: this.options.endpoint,
+ endpointType: this.options.endpointType,
+ ...endpointOptions,
+ };
+
+ const existingConvo =
+ this.fetchedConvo === true
+ ? null
+ : await getConvo(this.options?.req?.user?.id, message.conversationId);
+
+ const unsetFields = {};
+ const exceptions = new Set(['spec', 'iconURL']);
+ const hasNonEphemeralAgent =
+ isAgentsEndpoint(this.options.endpoint) &&
+ endpointOptions?.agent_id &&
+ endpointOptions.agent_id !== Constants.EPHEMERAL_AGENT_ID;
+ if (hasNonEphemeralAgent) {
+ exceptions.add('model');
+ }
+ if (existingConvo != null) {
+ this.fetchedConvo = true;
+ for (const key in existingConvo) {
+ if (!key) {
+ continue;
+ }
+ if (excludedKeys.has(key) && !exceptions.has(key)) {
+ continue;
+ }
+
+ if (endpointOptions?.[key] === undefined) {
+ unsetFields[key] = 1;
+ }
+ }
+ }
+
+ const conversation = await saveConvo(this.options?.req, fieldsToKeep, {
+ context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveConvo',
+ unsetFields,
+ });
+
+ return { message: savedMessage, conversation };
+ }
+
+ /**
+ * Update a message in the database.
+ * @param {Partial} message
+ */
+ async updateMessageInDatabase(message) {
+ await updateMessage(this.options.req, message);
+ }
+
+ /**
+ * Iterate through messages, building an array based on the parentMessageId.
+ *
+ * This function constructs a conversation thread by traversing messages from a given parentMessageId up to the root message.
+ * It handles cyclic references by ensuring that a message is not processed more than once.
+ * If the 'summary' option is set to true and a message has a 'summary' property:
+ * - The message's 'role' is set to 'system'.
+ * - The message's 'text' is set to its 'summary'.
+ * - If the message has a 'summaryTokenCount', the message's 'tokenCount' is set to 'summaryTokenCount'.
+ * The traversal stops at the message with the 'summary' property.
+ *
+ * Each message object should have an 'id' or 'messageId' property and may have a 'parentMessageId' property.
+ * The 'parentMessageId' is the ID of the message that the current message is a reply to.
+ * If 'parentMessageId' is not present, null, or is Constants.NO_PARENT,
+ * the message is considered a root message.
+ *
+ * @param {Object} options - The options for the function.
+ * @param {TMessage[]} options.messages - An array of message objects. Each object should have either an 'id' or 'messageId' property, and may have a 'parentMessageId' property.
+ * @param {string} options.parentMessageId - The ID of the parent message to start the traversal from.
+ * @param {Function} [options.mapMethod] - An optional function to map over the ordered messages. If provided, it will be applied to each message in the resulting array.
+ * @param {boolean} [options.summary=false] - If set to true, the traversal modifies messages with 'summary' and 'summaryTokenCount' properties and stops at the message with a 'summary' property.
+ * @returns {TMessage[]} An array containing the messages in the order they should be displayed, starting with the most recent message with a 'summary' property if the 'summary' option is true, and ending with the message identified by 'parentMessageId'.
+ */
+ static getMessagesForConversation({
+ messages,
+ parentMessageId,
+ mapMethod = null,
+ summary = false,
+ }) {
+ if (!messages || messages.length === 0) {
+ return [];
+ }
+
+ const orderedMessages = [];
+ let currentMessageId = parentMessageId;
+ const visitedMessageIds = new Set();
+
+ while (currentMessageId) {
+ if (visitedMessageIds.has(currentMessageId)) {
+ break;
+ }
+ const message = messages.find((msg) => {
+ const messageId = msg.messageId ?? msg.id;
+ return messageId === currentMessageId;
+ });
+
+ visitedMessageIds.add(currentMessageId);
+
+ if (!message) {
+ break;
+ }
+
+ if (summary && message.summary) {
+ message.role = 'system';
+ message.text = message.summary;
+ }
+
+ if (summary && message.summaryTokenCount) {
+ message.tokenCount = message.summaryTokenCount;
+ }
+
+ orderedMessages.push(message);
+
+ if (summary && message.summary) {
+ break;
+ }
+
+ currentMessageId =
+ message.parentMessageId === Constants.NO_PARENT ? null : message.parentMessageId;
+ }
+
+ orderedMessages.reverse();
+
+ if (mapMethod) {
+ return orderedMessages.map(mapMethod);
+ }
+
+ return orderedMessages;
+ }
+
+ /**
+ * Algorithm adapted from "6. Counting tokens for chat API calls" of
+ * https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb
+ *
+ * An additional 3 tokens need to be added for assistant label priming after all messages have been counted.
+ * In our implementation, this is accounted for in the getMessagesWithinTokenLimit method.
+ *
+ * The content parts example was adapted from the following example:
+ * https://github.com/openai/openai-cookbook/pull/881/files
+ *
+ * Note: image token calculation is to be done elsewhere where we have access to the image metadata
+ *
+ * @param {Object} message
+ */
+ getTokenCountForMessage(message) {
+ // Note: gpt-3.5-turbo and gpt-4 may update over time. Use default for these as well as for unknown models
+ let tokensPerMessage = 3;
+ let tokensPerName = 1;
+ const model = this.modelOptions?.model ?? this.model;
+
+ if (model === 'gpt-3.5-turbo-0301') {
+ tokensPerMessage = 4;
+ tokensPerName = -1;
+ }
+
+ const processValue = (value) => {
+ if (Array.isArray(value)) {
+ for (let item of value) {
+ if (
+ !item ||
+ !item.type ||
+ item.type === ContentTypes.THINK ||
+ item.type === ContentTypes.ERROR ||
+ item.type === ContentTypes.IMAGE_URL
+ ) {
+ continue;
+ }
+
+ if (item.type === ContentTypes.TOOL_CALL && item.tool_call != null) {
+ const toolName = item.tool_call?.name || '';
+ if (toolName != null && toolName && typeof toolName === 'string') {
+ numTokens += this.getTokenCount(toolName);
+ }
+
+ const args = item.tool_call?.args || '';
+ if (args != null && args && typeof args === 'string') {
+ numTokens += this.getTokenCount(args);
+ }
+
+ const output = item.tool_call?.output || '';
+ if (output != null && output && typeof output === 'string') {
+ numTokens += this.getTokenCount(output);
+ }
+ continue;
+ }
+
+ const nestedValue = item[item.type];
+
+ if (!nestedValue) {
+ continue;
+ }
+
+ processValue(nestedValue);
+ }
+ } else if (typeof value === 'string') {
+ numTokens += this.getTokenCount(value);
+ } else if (typeof value === 'number') {
+ numTokens += this.getTokenCount(value.toString());
+ } else if (typeof value === 'boolean') {
+ numTokens += this.getTokenCount(value.toString());
+ }
+ };
+
+ let numTokens = tokensPerMessage;
+ for (let [key, value] of Object.entries(message)) {
+ processValue(value);
+
+ if (key === 'name') {
+ numTokens += tokensPerName;
+ }
+ }
+ return numTokens;
+ }
+
+ /**
+ * Merges completion content with existing content when editing TEXT or THINK types
+ * @param {Array} existingContent - The existing content array
+ * @param {Array} newCompletion - The new completion content
+ * @param {string} editedType - The type of content being edited
+ * @returns {Array} The merged content array
+ */
+ mergeEditedContent(existingContent, newCompletion, editedType) {
+ if (!newCompletion.length) {
+ return existingContent.concat(newCompletion);
+ }
+
+ if (editedType !== ContentTypes.TEXT && editedType !== ContentTypes.THINK) {
+ return existingContent.concat(newCompletion);
+ }
+
+ const lastIndex = existingContent.length - 1;
+ const lastExisting = existingContent[lastIndex];
+ const firstNew = newCompletion[0];
+
+ if (lastExisting?.type !== firstNew?.type || firstNew?.type !== editedType) {
+ return existingContent.concat(newCompletion);
+ }
+
+ const mergedContent = [...existingContent];
+ if (editedType === ContentTypes.TEXT) {
+ mergedContent[lastIndex] = {
+ ...mergedContent[lastIndex],
+ [ContentTypes.TEXT]:
+ (mergedContent[lastIndex][ContentTypes.TEXT] || '') + (firstNew[ContentTypes.TEXT] || ''),
+ };
+ } else {
+ mergedContent[lastIndex] = {
+ ...mergedContent[lastIndex],
+ [ContentTypes.THINK]:
+ (mergedContent[lastIndex][ContentTypes.THINK] || '') +
+ (firstNew[ContentTypes.THINK] || ''),
+ };
+ }
+
+ // Add remaining completion items
+ return mergedContent.concat(newCompletion.slice(1));
+ }
+
+ async sendPayload(payload, opts = {}) {
+ if (opts && typeof opts === 'object') {
+ this.setOptions(opts);
+ }
+
+ return await this.sendCompletion(payload, opts);
+ }
+
+ async addDocuments(message, attachments) {
+ const documentResult = await encodeAndFormatDocuments(
+ this.options.req,
+ attachments,
+ {
+ provider: this.options.agent?.provider ?? this.options.endpoint,
+ endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
+ useResponsesApi: this.options.agent?.model_parameters?.useResponsesApi,
+ },
+ getStrategyFunctions,
+ );
+ message.documents =
+ documentResult.documents && documentResult.documents.length
+ ? documentResult.documents
+ : undefined;
+ return documentResult.files;
+ }
+
+ async addVideos(message, attachments) {
+ const videoResult = await encodeAndFormatVideos(
+ this.options.req,
+ attachments,
+ {
+ provider: this.options.agent?.provider ?? this.options.endpoint,
+ endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
+ },
+ getStrategyFunctions,
+ );
+ message.videos =
+ videoResult.videos && videoResult.videos.length ? videoResult.videos : undefined;
+ return videoResult.files;
+ }
+
+ async addAudios(message, attachments) {
+ const audioResult = await encodeAndFormatAudios(
+ this.options.req,
+ attachments,
+ {
+ provider: this.options.agent?.provider ?? this.options.endpoint,
+ endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
+ },
+ getStrategyFunctions,
+ );
+ message.audios =
+ audioResult.audios && audioResult.audios.length ? audioResult.audios : undefined;
+ return audioResult.files;
+ }
+
+ /**
+ * Extracts text context from attachments and sets it on the message.
+ * This handles text that was already extracted from files (OCR, transcriptions, document text, etc.)
+ * @param {TMessage} message - The message to add context to
+ * @param {MongoFile[]} attachments - Array of file attachments
+ * @returns {Promise}
+ */
+ async addFileContextToMessage(message, attachments) {
+ const fileContext = await extractFileContext({
+ attachments,
+ req: this.options?.req,
+ tokenCountFn: (text) => countTokens(text),
+ });
+
+ if (fileContext) {
+ message.fileContext = fileContext;
+ }
+ }
+
+ async processAttachments(message, attachments) {
+ const categorizedAttachments = {
+ images: [],
+ videos: [],
+ audios: [],
+ documents: [],
+ };
+
+ const allFiles = [];
+
+ for (const file of attachments) {
+ /** @type {FileSources} */
+ const source = file.source ?? FileSources.local;
+ if (source === FileSources.text) {
+ allFiles.push(file);
+ continue;
+ }
+ if (file.embedded === true || file.metadata?.fileIdentifier != null) {
+ allFiles.push(file);
+ continue;
+ }
+
+ if (file.type.startsWith('image/')) {
+ categorizedAttachments.images.push(file);
+ } else if (file.type === 'application/pdf') {
+ categorizedAttachments.documents.push(file);
+ allFiles.push(file);
+ } else if (file.type.startsWith('video/')) {
+ categorizedAttachments.videos.push(file);
+ allFiles.push(file);
+ } else if (file.type.startsWith('audio/')) {
+ categorizedAttachments.audios.push(file);
+ allFiles.push(file);
+ }
+ }
+
+ const [imageFiles] = await Promise.all([
+ categorizedAttachments.images.length > 0
+ ? this.addImageURLs(message, categorizedAttachments.images)
+ : Promise.resolve([]),
+ categorizedAttachments.documents.length > 0
+ ? this.addDocuments(message, categorizedAttachments.documents)
+ : Promise.resolve([]),
+ categorizedAttachments.videos.length > 0
+ ? this.addVideos(message, categorizedAttachments.videos)
+ : Promise.resolve([]),
+ categorizedAttachments.audios.length > 0
+ ? this.addAudios(message, categorizedAttachments.audios)
+ : Promise.resolve([]),
+ ]);
+
+ allFiles.push(...imageFiles);
+
+ const seenFileIds = new Set();
+ const uniqueFiles = [];
+
+ for (const file of allFiles) {
+ if (file.file_id && !seenFileIds.has(file.file_id)) {
+ seenFileIds.add(file.file_id);
+ uniqueFiles.push(file);
+ } else if (!file.file_id) {
+ uniqueFiles.push(file);
+ }
+ }
+
+ return uniqueFiles;
+ }
+
+ /**
+ * @param {TMessage[]} _messages
+ * @returns {Promise}
+ */
+ async addPreviousAttachments(_messages) {
+ if (!this.options.resendFiles) {
+ return _messages;
+ }
+
+ const seen = new Set();
+ const attachmentsProcessed =
+ this.options.attachments && !(this.options.attachments instanceof Promise);
+ if (attachmentsProcessed) {
+ for (const attachment of this.options.attachments) {
+ seen.add(attachment.file_id);
+ }
+ }
+
+ /**
+ *
+ * @param {TMessage} message
+ */
+ const processMessage = async (message) => {
+ if (!this.message_file_map) {
+ /** @type {Record */
+ this.message_file_map = {};
+ }
+
+ const fileIds = [];
+ for (const file of message.files) {
+ if (seen.has(file.file_id)) {
+ continue;
+ }
+ fileIds.push(file.file_id);
+ seen.add(file.file_id);
+ }
+
+ if (fileIds.length === 0) {
+ return message;
+ }
+
+ const files = await getFiles(
+ {
+ file_id: { $in: fileIds },
+ },
+ {},
+ {},
+ );
+
+ await this.addFileContextToMessage(message, files);
+ await this.processAttachments(message, files);
+
+ this.message_file_map[message.messageId] = files;
+ return message;
+ };
+
+ const promises = [];
+
+ for (const message of _messages) {
+ if (!message.files) {
+ promises.push(message);
+ continue;
+ }
+
+ promises.push(processMessage(message));
+ }
+
+ const messages = await Promise.all(promises);
+
+ this.checkVisionRequest(Object.values(this.message_file_map ?? {}).flat());
+ return messages;
+ }
+}
+
+module.exports = BaseClient;
diff --git a/api/app/clients/OllamaClient.js b/api/app/clients/OllamaClient.js
new file mode 100644
index 0000000000..d0dda519fe
--- /dev/null
+++ b/api/app/clients/OllamaClient.js
@@ -0,0 +1,167 @@
+const { z } = require('zod');
+const axios = require('axios');
+const { Ollama } = require('ollama');
+const { sleep } = require('@librechat/agents');
+const { logger } = require('@librechat/data-schemas');
+const { Constants } = require('librechat-data-provider');
+const { resolveHeaders, deriveBaseURL } = require('@librechat/api');
+
+const ollamaPayloadSchema = z.object({
+ mirostat: z.number().optional(),
+ mirostat_eta: z.number().optional(),
+ mirostat_tau: z.number().optional(),
+ num_ctx: z.number().optional(),
+ repeat_last_n: z.number().optional(),
+ repeat_penalty: z.number().optional(),
+ temperature: z.number().optional(),
+ seed: z.number().nullable().optional(),
+ stop: z.array(z.string()).optional(),
+ tfs_z: z.number().optional(),
+ num_predict: z.number().optional(),
+ top_k: z.number().optional(),
+ top_p: z.number().optional(),
+ stream: z.optional(z.boolean()),
+ model: z.string(),
+});
+
+/**
+ * @param {string} imageUrl
+ * @returns {string}
+ * @throws {Error}
+ */
+const getValidBase64 = (imageUrl) => {
+ const parts = imageUrl.split(';base64,');
+
+ if (parts.length === 2) {
+ return parts[1];
+ } else {
+ logger.error('Invalid or no Base64 string found in URL.');
+ }
+};
+
+class OllamaClient {
+ constructor(options = {}) {
+ const host = deriveBaseURL(options.baseURL ?? 'http://localhost:11434');
+ this.streamRate = options.streamRate ?? Constants.DEFAULT_STREAM_RATE;
+ this.headers = options.headers ?? {};
+ /** @type {Ollama} */
+ this.client = new Ollama({ host });
+ }
+
+ /**
+ * Fetches Ollama models from the specified base API path.
+ * @param {string} baseURL
+ * @param {Object} [options] - Optional configuration
+ * @param {Partial} [options.user] - User object for header resolution
+ * @param {Record} [options.headers] - Headers to include in the request
+ * @returns {Promise} The Ollama models.
+ * @throws {Error} Throws if the Ollama API request fails
+ */
+ static async fetchModels(baseURL, options = {}) {
+ if (!baseURL) {
+ return [];
+ }
+
+ const ollamaEndpoint = deriveBaseURL(baseURL);
+
+ const resolvedHeaders = resolveHeaders({
+ headers: options.headers,
+ user: options.user,
+ });
+
+ /** @type {Promise>} */
+ const response = await axios.get(`${ollamaEndpoint}/api/tags`, {
+ headers: resolvedHeaders,
+ timeout: 5000,
+ });
+
+ const models = response.data.models.map((tag) => tag.name);
+ return models;
+ }
+
+ /**
+ * @param {ChatCompletionMessage[]} messages
+ * @returns {OllamaMessage[]}
+ */
+ static formatOpenAIMessages(messages) {
+ const ollamaMessages = [];
+
+ for (const message of messages) {
+ if (typeof message.content === 'string') {
+ ollamaMessages.push({
+ role: message.role,
+ content: message.content,
+ });
+ continue;
+ }
+
+ let aggregatedText = '';
+ let imageUrls = [];
+
+ for (const content of message.content) {
+ if (content.type === 'text') {
+ aggregatedText += content.text + ' ';
+ } else if (content.type === 'image_url') {
+ imageUrls.push(getValidBase64(content.image_url.url));
+ }
+ }
+
+ const ollamaMessage = {
+ role: message.role,
+ content: aggregatedText.trim(),
+ };
+
+ if (imageUrls.length > 0) {
+ ollamaMessage.images = imageUrls;
+ }
+
+ ollamaMessages.push(ollamaMessage);
+ }
+
+ return ollamaMessages;
+ }
+
+ /***
+ * @param {Object} params
+ * @param {ChatCompletionPayload} params.payload
+ * @param {onTokenProgress} params.onProgress
+ * @param {AbortController} params.abortController
+ */
+ async chatCompletion({ payload, onProgress, abortController = null }) {
+ let intermediateReply = '';
+
+ const parameters = ollamaPayloadSchema.parse(payload);
+ const messages = OllamaClient.formatOpenAIMessages(payload.messages);
+
+ if (parameters.stream) {
+ const stream = await this.client.chat({
+ messages,
+ ...parameters,
+ });
+
+ for await (const chunk of stream) {
+ const token = chunk.message.content;
+ intermediateReply += token;
+ onProgress(token);
+ if (abortController.signal.aborted) {
+ stream.controller.abort();
+ break;
+ }
+
+ await sleep(this.streamRate);
+ }
+ }
+ // TODO: regular completion
+ else {
+ // const generation = await this.client.generate(payload);
+ }
+
+ return intermediateReply;
+ }
+ catch(err) {
+ logger.error('[OllamaClient.chatCompletion]', err);
+ throw err;
+ }
+}
+
+module.exports = { OllamaClient, ollamaPayloadSchema };
diff --git a/api/app/clients/TextStream.js b/api/app/clients/TextStream.js
new file mode 100644
index 0000000000..ea3d8980e9
--- /dev/null
+++ b/api/app/clients/TextStream.js
@@ -0,0 +1,60 @@
+const { Readable } = require('stream');
+const { logger } = require('@librechat/data-schemas');
+
+class TextStream extends Readable {
+ constructor(text, options = {}) {
+ super(options);
+ this.text = text;
+ this.currentIndex = 0;
+ this.minChunkSize = options.minChunkSize ?? 2;
+ this.maxChunkSize = options.maxChunkSize ?? 4;
+ this.delay = options.delay ?? 20; // Time in milliseconds
+ }
+
+ _read() {
+ const { delay, minChunkSize, maxChunkSize } = this;
+
+ if (this.currentIndex < this.text.length) {
+ setTimeout(() => {
+ const remainingChars = this.text.length - this.currentIndex;
+ const chunkSize = Math.min(this.randomInt(minChunkSize, maxChunkSize + 1), remainingChars);
+
+ const chunk = this.text.slice(this.currentIndex, this.currentIndex + chunkSize);
+ this.push(chunk);
+ this.currentIndex += chunkSize;
+ }, delay);
+ } else {
+ this.push(null); // signal end of data
+ }
+ }
+
+ randomInt(min, max) {
+ return Math.floor(Math.random() * (max - min)) + min;
+ }
+
+ async processTextStream(onProgressCallback) {
+ const streamPromise = new Promise((resolve, reject) => {
+ this.on('data', (chunk) => {
+ onProgressCallback(chunk.toString());
+ });
+
+ this.on('end', () => {
+ // logger.debug('[processTextStream] Stream ended');
+ resolve();
+ });
+
+ this.on('error', (err) => {
+ reject(err);
+ });
+ });
+
+ try {
+ await streamPromise;
+ } catch (err) {
+ logger.error('[processTextStream] Error in text stream:', err);
+ // Handle the error appropriately, e.g., return an error message or throw an error
+ }
+ }
+}
+
+module.exports = TextStream;
diff --git a/api/app/clients/index.js b/api/app/clients/index.js
new file mode 100644
index 0000000000..3dbe397b31
--- /dev/null
+++ b/api/app/clients/index.js
@@ -0,0 +1,7 @@
+const TextStream = require('./TextStream');
+const toolUtils = require('./tools/util');
+
+module.exports = {
+ TextStream,
+ ...toolUtils,
+};
diff --git a/api/app/clients/prompts/artifacts.js b/api/app/clients/prompts/artifacts.js
new file mode 100644
index 0000000000..915ccae629
--- /dev/null
+++ b/api/app/clients/prompts/artifacts.js
@@ -0,0 +1,537 @@
+const dedent = require('dedent');
+const { EModelEndpoint, ArtifactModes } = require('librechat-data-provider');
+const { generateShadcnPrompt } = require('~/app/clients/prompts/shadcn-docs/generate');
+const { components } = require('~/app/clients/prompts/shadcn-docs/components');
+
+/** @deprecated */
+// eslint-disable-next-line no-unused-vars
+const artifactsPromptV1 = dedent`The assistant can create and reference artifacts during conversations.
+
+Artifacts are for substantial, self-contained content that users might modify or reuse, displayed in a separate UI window for clarity.
+
+# Good artifacts are...
+- Substantial content (>15 lines)
+- Content that the user is likely to modify, iterate on, or take ownership of
+- Self-contained, complex content that can be understood on its own, without context from the conversation
+- Content intended for eventual use outside the conversation (e.g., reports, emails, presentations)
+- Content likely to be referenced or reused multiple times
+
+# Don't use artifacts for...
+- Simple, informational, or short content, such as brief code snippets, mathematical equations, or small examples
+- Primarily explanatory, instructional, or illustrative content, such as examples provided to clarify a concept
+- Suggestions, commentary, or feedback on existing artifacts
+- Conversational or explanatory content that doesn't represent a standalone piece of work
+- Content that is dependent on the current conversational context to be useful
+- Content that is unlikely to be modified or iterated upon by the user
+- Request from users that appears to be a one-off question
+
+# Usage notes
+- One artifact per message unless specifically requested
+- Prefer in-line content (don't use artifacts) when possible. Unnecessary use of artifacts can be jarring for users.
+- If a user asks the assistant to "draw an SVG" or "make a website," the assistant does not need to explain that it doesn't have these capabilities. Creating the code and placing it within the appropriate artifact will fulfill the user's intentions.
+- If asked to generate an image, the assistant can offer an SVG instead. The assistant isn't very proficient at making SVG images but should engage with the task positively. Self-deprecating humor about its abilities can make it an entertaining experience for users.
+- The assistant errs on the side of simplicity and avoids overusing artifacts for content that can be effectively presented within the conversation.
+- Always provide complete, specific, and fully functional content without any placeholders, ellipses, or 'remains the same' comments.
+
+
+ When collaborating with the user on creating content that falls into compatible categories, the assistant should follow these steps:
+
+ 1. Create the artifact using the following format:
+
+ :::artifact{identifier="unique-identifier" type="mime-type" title="Artifact Title"}
+ \`\`\`
+ Your artifact content here
+ \`\`\`
+ :::
+
+ 2. Assign an identifier to the \`identifier\` attribute. For updates, reuse the prior identifier. For new artifacts, the identifier should be descriptive and relevant to the content, using kebab-case (e.g., "example-code-snippet"). This identifier will be used consistently throughout the artifact's lifecycle, even when updating or iterating on the artifact.
+ 3. Include a \`title\` attribute to provide a brief title or description of the content.
+ 4. Add a \`type\` attribute to specify the type of content the artifact represents. Assign one of the following values to the \`type\` attribute:
+ - HTML: "text/html"
+ - The user interface can render single file HTML pages placed within the artifact tags. HTML, JS, and CSS should be in a single file when using the \`text/html\` type.
+ - Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \` \`
+ - The only place external scripts can be imported from is https://cdnjs.cloudflare.com
+ - Mermaid Diagrams: "application/vnd.mermaid"
+ - The user interface will render Mermaid diagrams placed within the artifact tags.
+ - React Components: "application/vnd.react"
+ - Use this for displaying either: React elements, e.g. \`Hello World! \`, React pure functional components, e.g. \`() => Hello World! \`, React functional components with Hooks, or React component classes
+ - When creating a React component, ensure it has no required props (or provide default values for all props) and use a default export.
+ - Use Tailwind classes for styling. DO NOT USE ARBITRARY VALUES (e.g. \`h-[600px]\`).
+ - Base React is available to be imported. To use hooks, first import it at the top of the artifact, e.g. \`import { useState } from "react"\`
+ - The lucide-react@0.263.1 library is available to be imported. e.g. \`import { Camera } from "lucide-react"\` & \` \`
+ - The recharts charting library is available to be imported, e.g. \`import { LineChart, XAxis, ... } from "recharts"\` & \` ...\`
+ - The assistant can use prebuilt components from the \`shadcn/ui\` library after it is imported: \`import { Alert, AlertDescription, AlertTitle, AlertDialog, AlertDialogAction } from '/components/ui/alert';\`. If using components from the shadcn/ui library, the assistant mentions this to the user and offers to help them install the components if necessary.
+ - Components MUST be imported from \`/components/ui/name\` and NOT from \`/components/name\` or \`@/components/ui/name\`.
+ - NO OTHER LIBRARIES (e.g. zod, hookform) ARE INSTALLED OR ABLE TO BE IMPORTED.
+ - Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \` \`
+ - If you are unable to follow the above requirements for any reason, don't use artifacts and use regular code blocks instead, which will not attempt to render the component.
+ 5. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
+ 6. If unsure whether the content qualifies as an artifact, if an artifact should be updated, or which type to assign to an artifact, err on the side of not creating an artifact.
+ 7. Always use triple backticks (\`\`\`) to enclose the content within the artifact, regardless of the content type.
+
+
+Here are some examples of correct usage of artifacts:
+
+
+
+ This example demonstrates how to create a Mermaid artifact for a simple flow chart.
+
+
+
+ Can you create a simple flow chart showing the process of making tea using Mermaid?
+
+
+ Sure! Here's a simple flow chart depicting the process of making tea using Mermaid syntax:
+
+ :::artifact{identifier="tea-making-flowchart" type="application/vnd.mermaid" title="Flow chart: Making Tea"}
+ \`\`\`mermaid
+ graph TD
+ A[Start] --> B{Water boiled?}
+ B -->|Yes| C[Add tea leaves to cup]
+ B -->|No| D[Boil water]
+ D --> B
+ C --> E[Pour boiling water into cup]
+ E --> F[Steep tea for desired time]
+ F --> G[Remove tea leaves]
+ G --> H[Add milk or sugar, if desired]
+ H --> I[Enjoy your tea!]
+ I --> J[End]
+ \`\`\`
+ :::
+
+ This flow chart uses Mermaid syntax to visualize the steps involved in making a cup of tea. Here's a brief explanation of the process:
+
+ 1. Start
+ 2. Check if water is boiled
+ 3. If not boiled, boil the water
+ 4. Once water is boiled, add tea leaves to the cup
+ 5. Pour boiling water into the cup
+ 6. Steep the tea for the desired time
+ 7. Remove the tea leaves
+ 8. Optionally add milk or sugar
+ 9. Enjoy your tea!
+ 10. End
+
+ This chart provides a clear visual representation of the tea-making process. You can easily modify or expand this chart if you want to add more details or steps to the process. Let me know if you'd like any changes or have any questions!
+
+
+ `;
+
+const artifactsPrompt = dedent`The assistant can create and reference artifacts during conversations.
+
+Artifacts are for substantial, self-contained content that users might modify or reuse, displayed in a separate UI window for clarity.
+
+# Good artifacts are...
+- Substantial content (>15 lines)
+- Content that the user is likely to modify, iterate on, or take ownership of
+- Self-contained, complex content that can be understood on its own, without context from the conversation
+- Content intended for eventual use outside the conversation (e.g., reports, emails, presentations)
+- Content likely to be referenced or reused multiple times
+
+# Don't use artifacts for...
+- Simple, informational, or short content, such as brief code snippets, mathematical equations, or small examples
+- Primarily explanatory, instructional, or illustrative content, such as examples provided to clarify a concept
+- Suggestions, commentary, or feedback on existing artifacts
+- Conversational or explanatory content that doesn't represent a standalone piece of work
+- Content that is dependent on the current conversational context to be useful
+- Content that is unlikely to be modified or iterated upon by the user
+- Request from users that appears to be a one-off question
+
+# Usage notes
+- One artifact per message unless specifically requested
+- Prefer in-line content (don't use artifacts) when possible. Unnecessary use of artifacts can be jarring for users.
+- If a user asks the assistant to "draw an SVG" or "make a website," the assistant does not need to explain that it doesn't have these capabilities. Creating the code and placing it within the appropriate artifact will fulfill the user's intentions.
+- If asked to generate an image, the assistant can offer an SVG instead. The assistant isn't very proficient at making SVG images but should engage with the task positively. Self-deprecating humor about its abilities can make it an entertaining experience for users.
+- The assistant errs on the side of simplicity and avoids overusing artifacts for content that can be effectively presented within the conversation.
+- Always provide complete, specific, and fully functional content for artifacts without any snippets, placeholders, ellipses, or 'remains the same' comments.
+- If an artifact is not necessary or requested, the assistant should not mention artifacts at all, and respond to the user accordingly.
+
+
+ When collaborating with the user on creating content that falls into compatible categories, the assistant should follow these steps:
+
+ 1. Create the artifact using the following format:
+
+ :::artifact{identifier="unique-identifier" type="mime-type" title="Artifact Title"}
+ \`\`\`
+ Your artifact content here
+ \`\`\`
+ :::
+
+ 2. Assign an identifier to the \`identifier\` attribute. For updates, reuse the prior identifier. For new artifacts, the identifier should be descriptive and relevant to the content, using kebab-case (e.g., "example-code-snippet"). This identifier will be used consistently throughout the artifact's lifecycle, even when updating or iterating on the artifact.
+ 3. Include a \`title\` attribute to provide a brief title or description of the content.
+ 4. Add a \`type\` attribute to specify the type of content the artifact represents. Assign one of the following values to the \`type\` attribute:
+ - HTML: "text/html"
+ - The user interface can render single file HTML pages placed within the artifact tags. HTML, JS, and CSS should be in a single file when using the \`text/html\` type.
+ - Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \` \`
+ - The only place external scripts can be imported from is https://cdnjs.cloudflare.com
+ - SVG: "image/svg+xml"
+ - The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
+ - The assistant should specify the viewbox of the SVG rather than defining a width/height
+ - Markdown: "text/markdown" or "text/md"
+ - The user interface will render Markdown content placed within the artifact tags.
+ - Supports standard Markdown syntax including headers, lists, links, images, code blocks, tables, and more.
+ - Both "text/markdown" and "text/md" are accepted as valid MIME types for Markdown content.
+ - Mermaid Diagrams: "application/vnd.mermaid"
+ - The user interface will render Mermaid diagrams placed within the artifact tags.
+ - React Components: "application/vnd.react"
+ - Use this for displaying either: React elements, e.g. \`Hello World! \`, React pure functional components, e.g. \`() => Hello World! \`, React functional components with Hooks, or React component classes
+ - When creating a React component, ensure it has no required props (or provide default values for all props) and use a default export.
+ - Use Tailwind classes for styling. DO NOT USE ARBITRARY VALUES (e.g. \`h-[600px]\`).
+ - Base React is available to be imported. To use hooks, first import it at the top of the artifact, e.g. \`import { useState } from "react"\`
+ - The lucide-react@0.394.0 library is available to be imported. e.g. \`import { Camera } from "lucide-react"\` & \` \`
+ - The recharts charting library is available to be imported, e.g. \`import { LineChart, XAxis, ... } from "recharts"\` & \` ...\`
+ - The three.js library is available to be imported, e.g. \`import * as THREE from "three";\`
+ - The date-fns library is available to be imported, e.g. \`import { compareAsc, format } from "date-fns";\`
+ - The react-day-picker library is available to be imported, e.g. \`import { DayPicker } from "react-day-picker";\`
+ - The assistant can use prebuilt components from the \`shadcn/ui\` library after it is imported: \`import { Alert, AlertDescription, AlertTitle, AlertDialog, AlertDialogAction } from '/components/ui/alert';\`. If using components from the shadcn/ui library, the assistant mentions this to the user and offers to help them install the components if necessary.
+ - Components MUST be imported from \`/components/ui/name\` and NOT from \`/components/name\` or \`@/components/ui/name\`.
+ - NO OTHER LIBRARIES (e.g. zod, hookform) ARE INSTALLED OR ABLE TO BE IMPORTED.
+ - Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \` \`
+ - When iterating on code, ensure that the code is complete and functional without any snippets, placeholders, or ellipses.
+ - If you are unable to follow the above requirements for any reason, don't use artifacts and use regular code blocks instead, which will not attempt to render the component.
+ 5. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
+ 6. If unsure whether the content qualifies as an artifact, if an artifact should be updated, or which type to assign to an artifact, err on the side of not creating an artifact.
+ 7. Always use triple backticks (\`\`\`) to enclose the content within the artifact, regardless of the content type.
+
+
+Here are some examples of correct usage of artifacts:
+
+
+
+ This example demonstrates how to create a Mermaid artifact for a simple flow chart.
+
+
+
+ Can you create a simple flow chart showing the process of making tea using Mermaid?
+
+
+ Sure! Here's a simple flow chart depicting the process of making tea using Mermaid syntax:
+
+ :::artifact{identifier="tea-making-flowchart" type="application/vnd.mermaid" title="Flow chart: Making Tea"}
+ \`\`\`mermaid
+ graph TD
+ A[Start] --> B{Water boiled?}
+ B -->|Yes| C[Add tea leaves to cup]
+ B -->|No| D[Boil water]
+ D --> B
+ C --> E[Pour boiling water into cup]
+ E --> F[Steep tea for desired time]
+ F --> G[Remove tea leaves]
+ G --> H[Add milk or sugar, if desired]
+ H --> I[Enjoy your tea!]
+ I --> J[End]
+ \`\`\`
+ :::
+
+ This flow chart uses Mermaid syntax to visualize the steps involved in making a cup of tea. Here's a brief explanation of the process:
+
+ 1. Start
+ 2. Check if water is boiled
+ 3. If not boiled, boil the water
+ 4. Once water is boiled, add tea leaves to the cup
+ 5. Pour boiling water into the cup
+ 6. Steep the tea for the desired time
+ 7. Remove the tea leaves
+ 8. Optionally add milk or sugar
+ 9. Enjoy your tea!
+ 10. End
+
+ This chart provides a clear visual representation of the tea-making process. You can easily modify or expand this chart if you want to add more details or steps to the process. Let me know if you'd like any changes or have any questions!
+
+
+
+
+ Create a simple React counter component
+
+ Here's a simple React counter component:
+
+ :::artifact{identifier="react-counter" type="application/vnd.react" title="React Counter"}
+ \`\`\`
+ import { useState } from 'react';
+
+ export default function Counter() {
+ const [count, setCount] = useState(0);
+ return (
+
+
Count: {count}
+
setCount(count + 1)}>
+ Increment
+
+
+ );
+ }
+ \`\`\`
+ :::
+
+ This component creates a simple counter with an increment button.
+
+
+
+
+ Create a basic HTML structure for a blog post
+
+ Here's a basic HTML structure for a blog post:
+
+ :::artifact{identifier="blog-post-html" type="text/html" title="Blog Post HTML"}
+ \`\`\`
+
+
+
+
+
+ My Blog Post
+
+
+
+
+
+
+ This is the content of my blog post. It's short and sweet!
+
+
+
+
+
+ \`\`\`
+ :::
+
+ This HTML structure provides a simple layout for a blog post.
+
+
+ `;
+
+const artifactsOpenAIPrompt = dedent`The assistant can create and reference artifacts during conversations.
+
+Artifacts are for substantial, self-contained content that users might modify or reuse, displayed in a separate UI window for clarity.
+
+# Good artifacts are...
+- Substantial content (>15 lines)
+- Content that the user is likely to modify, iterate on, or take ownership of
+- Self-contained, complex content that can be understood on its own, without context from the conversation
+- Content intended for eventual use outside the conversation (e.g., reports, emails, presentations)
+- Content likely to be referenced or reused multiple times
+
+# Don't use artifacts for...
+- Simple, informational, or short content, such as brief code snippets, mathematical equations, or small examples
+- Primarily explanatory, instructional, or illustrative content, such as examples provided to clarify a concept
+- Suggestions, commentary, or feedback on existing artifacts
+- Conversational or explanatory content that doesn't represent a standalone piece of work
+- Content that is dependent on the current conversational context to be useful
+- Content that is unlikely to be modified or iterated upon by the user
+- Request from users that appears to be a one-off question
+
+# Usage notes
+- One artifact per message unless specifically requested
+- Prefer in-line content (don't use artifacts) when possible. Unnecessary use of artifacts can be jarring for users.
+- If a user asks the assistant to "draw an SVG" or "make a website," the assistant does not need to explain that it doesn't have these capabilities. Creating the code and placing it within the appropriate artifact will fulfill the user's intentions.
+- If asked to generate an image, the assistant can offer an SVG instead. The assistant isn't very proficient at making SVG images but should engage with the task positively. Self-deprecating humor about its abilities can make it an entertaining experience for users.
+- The assistant errs on the side of simplicity and avoids overusing artifacts for content that can be effectively presented within the conversation.
+- Always provide complete, specific, and fully functional content for artifacts without any snippets, placeholders, ellipses, or 'remains the same' comments.
+- If an artifact is not necessary or requested, the assistant should not mention artifacts at all, and respond to the user accordingly.
+
+## Artifact Instructions
+ When collaborating with the user on creating content that falls into compatible categories, the assistant should follow these steps:
+
+ 1. Create the artifact using the following remark-directive markdown format:
+
+ :::artifact{identifier="unique-identifier" type="mime-type" title="Artifact Title"}
+ \`\`\`
+ Your artifact content here
+ \`\`\`
+ :::
+
+ a. Example of correct format:
+
+ :::artifact{identifier="example-artifact" type="text/plain" title="Example Artifact"}
+ \`\`\`
+ This is the content of the artifact.
+ It can span multiple lines.
+ \`\`\`
+ :::
+
+ b. Common mistakes to avoid:
+ - Don't split the opening ::: line
+ - Don't add extra backticks outside the artifact structure
+ - Don't omit the closing :::
+
+ 2. Assign an identifier to the \`identifier\` attribute. For updates, reuse the prior identifier. For new artifacts, the identifier should be descriptive and relevant to the content, using kebab-case (e.g., "example-code-snippet"). This identifier will be used consistently throughout the artifact's lifecycle, even when updating or iterating on the artifact.
+ 3. Include a \`title\` attribute to provide a brief title or description of the content.
+ 4. Add a \`type\` attribute to specify the type of content the artifact represents. Assign one of the following values to the \`type\` attribute:
+ - HTML: "text/html"
+ - The user interface can render single file HTML pages placed within the artifact tags. HTML, JS, and CSS should be in a single file when using the \`text/html\` type.
+ - Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \` \`
+ - The only place external scripts can be imported from is https://cdnjs.cloudflare.com
+ - SVG: "image/svg+xml"
+ - The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
+ - The assistant should specify the viewbox of the SVG rather than defining a width/height
+ - Markdown: "text/markdown" or "text/md"
+ - The user interface will render Markdown content placed within the artifact tags.
+ - Supports standard Markdown syntax including headers, lists, links, images, code blocks, tables, and more.
+ - Both "text/markdown" and "text/md" are accepted as valid MIME types for Markdown content.
+ - Mermaid Diagrams: "application/vnd.mermaid"
+ - The user interface will render Mermaid diagrams placed within the artifact tags.
+ - React Components: "application/vnd.react"
+ - Use this for displaying either: React elements, e.g. \`Hello World! \`, React pure functional components, e.g. \`() => Hello World! \`, React functional components with Hooks, or React component classes
+ - When creating a React component, ensure it has no required props (or provide default values for all props) and use a default export.
+ - Use Tailwind classes for styling. DO NOT USE ARBITRARY VALUES (e.g. \`h-[600px]\`).
+ - Base React is available to be imported. To use hooks, first import it at the top of the artifact, e.g. \`import { useState } from "react"\`
+ - The lucide-react@0.394.0 library is available to be imported. e.g. \`import { Camera } from "lucide-react"\` & \` \`
+ - The recharts charting library is available to be imported, e.g. \`import { LineChart, XAxis, ... } from "recharts"\` & \` ...\`
+ - The three.js library is available to be imported, e.g. \`import * as THREE from "three";\`
+ - The date-fns library is available to be imported, e.g. \`import { compareAsc, format } from "date-fns";\`
+ - The react-day-picker library is available to be imported, e.g. \`import { DayPicker } from "react-day-picker";\`
+ - The assistant can use prebuilt components from the \`shadcn/ui\` library after it is imported: \`import { Alert, AlertDescription, AlertTitle, AlertDialog, AlertDialogAction } from '/components/ui/alert';\`. If using components from the shadcn/ui library, the assistant mentions this to the user and offers to help them install the components if necessary.
+ - Components MUST be imported from \`/components/ui/name\` and NOT from \`/components/name\` or \`@/components/ui/name\`.
+ - NO OTHER LIBRARIES (e.g. zod, hookform) ARE INSTALLED OR ABLE TO BE IMPORTED.
+ - Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \` \`
+ - When iterating on code, ensure that the code is complete and functional without any snippets, placeholders, or ellipses.
+ - If you are unable to follow the above requirements for any reason, don't use artifacts and use regular code blocks instead, which will not attempt to render the component.
+ 5. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
+ 6. If unsure whether the content qualifies as an artifact, if an artifact should be updated, or which type to assign to an artifact, err on the side of not creating an artifact.
+ 7. NEVER use triple backticks to enclose the artifact, ONLY the content within the artifact.
+
+Here are some examples of correct usage of artifacts:
+
+## Examples
+
+### Example 1
+
+ This example demonstrates how to create a Mermaid artifact for a simple flow chart.
+
+ User: Can you create a simple flow chart showing the process of making tea using Mermaid?
+
+ Assistant: Sure! Here's a simple flow chart depicting the process of making tea using Mermaid syntax:
+
+ :::artifact{identifier="tea-making-flowchart" type="application/vnd.mermaid" title="Flow chart: Making Tea"}
+ \`\`\`mermaid
+ graph TD
+ A[Start] --> B{Water boiled?}
+ B -->|Yes| C[Add tea leaves to cup]
+ B -->|No| D[Boil water]
+ D --> B
+ C --> E[Pour boiling water into cup]
+ E --> F[Steep tea for desired time]
+ F --> G[Remove tea leaves]
+ G --> H[Add milk or sugar, if desired]
+ H --> I[Enjoy your tea!]
+ I --> J[End]
+ \`\`\`
+ :::
+
+ This flow chart uses Mermaid syntax to visualize the steps involved in making a cup of tea. Here's a brief explanation of the process:
+
+ 1. Start
+ 2. Check if water is boiled
+ 3. If not boiled, boil the water
+ 4. Once water is boiled, add tea leaves to the cup
+ 5. Pour boiling water into the cup
+ 6. Steep the tea for the desired time
+ 7. Remove the tea leaves
+ 8. Optionally add milk or sugar
+ 9. Enjoy your tea!
+ 10. End
+
+ This chart provides a clear visual representation of the tea-making process. You can easily modify or expand this chart if you want to add more details or steps to the process. Let me know if you'd like any changes or have any questions!
+
+---
+
+### Example 2
+
+ User: Create a simple React counter component
+
+ Assistant: Here's a simple React counter component:
+
+ :::artifact{identifier="react-counter" type="application/vnd.react" title="React Counter"}
+ \`\`\`
+ import { useState } from 'react';
+
+ export default function Counter() {
+ const [count, setCount] = useState(0);
+ return (
+
+
Count: {count}
+
setCount(count + 1)}>
+ Increment
+
+
+ );
+ }
+ \`\`\`
+ :::
+
+ This component creates a simple counter with an increment button.
+
+---
+
+### Example 3
+ User: Create a basic HTML structure for a blog post
+ Assistant: Here's a basic HTML structure for a blog post:
+
+ :::artifact{identifier="blog-post-html" type="text/html" title="Blog Post HTML"}
+ \`\`\`
+
+
+
+
+
+ My Blog Post
+
+
+
+
+
+
+ This is the content of my blog post. It's short and sweet!
+
+
+
+
+
+ \`\`\`
+ :::
+
+ This HTML structure provides a simple layout for a blog post.
+
+---`;
+
+/**
+ *
+ * @param {Object} params
+ * @param {EModelEndpoint | string} params.endpoint - The current endpoint
+ * @param {ArtifactModes} params.artifacts - The current artifact mode
+ * @returns
+ */
+const generateArtifactsPrompt = ({ endpoint, artifacts }) => {
+ if (artifacts === ArtifactModes.CUSTOM) {
+ return null;
+ }
+
+ let prompt = artifactsPrompt;
+ if (endpoint !== EModelEndpoint.anthropic) {
+ prompt = artifactsOpenAIPrompt;
+ }
+
+ if (artifacts === ArtifactModes.SHADCNUI) {
+ prompt += generateShadcnPrompt({ components, useXML: endpoint === EModelEndpoint.anthropic });
+ }
+
+ return prompt;
+};
+
+module.exports = generateArtifactsPrompt;
diff --git a/api/app/clients/prompts/createContextHandlers.js b/api/app/clients/prompts/createContextHandlers.js
new file mode 100644
index 0000000000..87c48bcf92
--- /dev/null
+++ b/api/app/clients/prompts/createContextHandlers.js
@@ -0,0 +1,160 @@
+const axios = require('axios');
+const { logger } = require('@librechat/data-schemas');
+const { isEnabled, generateShortLivedToken } = require('@librechat/api');
+
+const footer = `Use the context as your learned knowledge to better answer the user.
+
+In your response, remember to follow these guidelines:
+- If you don't know the answer, simply say that you don't know.
+- If you are unsure how to answer, ask for clarification.
+- Avoid mentioning that you obtained the information from the context.
+`;
+
+function createContextHandlers(req, userMessageContent) {
+ if (!process.env.RAG_API_URL) {
+ return;
+ }
+
+ const queryPromises = [];
+ const processedFiles = [];
+ const processedIds = new Set();
+ const jwtToken = generateShortLivedToken(req.user.id);
+ const useFullContext = isEnabled(process.env.RAG_USE_FULL_CONTEXT);
+
+ const query = async (file) => {
+ if (useFullContext) {
+ return axios.get(`${process.env.RAG_API_URL}/documents/${file.file_id}/context`, {
+ headers: {
+ Authorization: `Bearer ${jwtToken}`,
+ },
+ });
+ }
+
+ return axios.post(
+ `${process.env.RAG_API_URL}/query`,
+ {
+ file_id: file.file_id,
+ query: userMessageContent,
+ k: 4,
+ },
+ {
+ headers: {
+ Authorization: `Bearer ${jwtToken}`,
+ 'Content-Type': 'application/json',
+ },
+ },
+ );
+ };
+
+ const processFile = async (file) => {
+ if (file.embedded && !processedIds.has(file.file_id)) {
+ try {
+ const promise = query(file);
+ queryPromises.push(promise);
+ processedFiles.push(file);
+ processedIds.add(file.file_id);
+ } catch (error) {
+ logger.error(`Error processing file ${file.filename}:`, error);
+ }
+ }
+ };
+
+ const createContext = async () => {
+ try {
+ if (!queryPromises.length || !processedFiles.length) {
+ return '';
+ }
+
+ const oneFile = processedFiles.length === 1;
+ const header = `The user has attached ${oneFile ? 'a' : processedFiles.length} file${
+ !oneFile ? 's' : ''
+ } to the conversation:`;
+
+ const files = `${
+ oneFile
+ ? ''
+ : `
+ `
+ }${processedFiles
+ .map(
+ (file) => `
+
+ ${file.filename}
+ ${file.type}
+ `,
+ )
+ .join('')}${
+ oneFile
+ ? ''
+ : `
+ `
+ }`;
+
+ const resolvedQueries = await Promise.all(queryPromises);
+
+ const context =
+ resolvedQueries.length === 0
+ ? '\n\tThe semantic search did not return any results.'
+ : resolvedQueries
+ .map((queryResult, index) => {
+ const file = processedFiles[index];
+ let contextItems = queryResult.data;
+
+ const generateContext = (currentContext) =>
+ `
+
+ ${file.filename}
+ ${currentContext}
+
+ `;
+
+ if (useFullContext) {
+ return generateContext(`\n${contextItems}`);
+ }
+
+ contextItems = queryResult.data
+ .map((item) => {
+ const pageContent = item[0].page_content;
+ return `
+
+
+ `;
+ })
+ .join('');
+
+ return generateContext(contextItems);
+ })
+ .join('');
+
+ if (useFullContext) {
+ const prompt = `${header}
+ ${context}
+ ${footer}`;
+
+ return prompt;
+ }
+
+ const prompt = `${header}
+ ${files}
+
+ A semantic search was executed with the user's message as the query, retrieving the following context inside XML tags.
+
+ ${context}
+
+
+ ${footer}`;
+
+ return prompt;
+ } catch (error) {
+ logger.error('Error creating context:', error);
+ throw error;
+ }
+ };
+
+ return {
+ processFile,
+ createContext,
+ };
+}
+
+module.exports = createContextHandlers;
diff --git a/api/app/clients/prompts/createVisionPrompt.js b/api/app/clients/prompts/createVisionPrompt.js
new file mode 100644
index 0000000000..5d8a7bbf51
--- /dev/null
+++ b/api/app/clients/prompts/createVisionPrompt.js
@@ -0,0 +1,34 @@
+/**
+ * Generates a prompt instructing the user to describe an image in detail, tailored to different types of visual content.
+ * @param {boolean} pluralized - Whether to pluralize the prompt for multiple images.
+ * @returns {string} - The generated vision prompt.
+ */
+const createVisionPrompt = (pluralized = false) => {
+ return `Please describe the image${
+ pluralized ? 's' : ''
+ } in detail, covering relevant aspects such as:
+
+ For photographs, illustrations, or artwork:
+ - The main subject(s) and their appearance, positioning, and actions
+ - The setting, background, and any notable objects or elements
+ - Colors, lighting, and overall mood or atmosphere
+ - Any interesting details, textures, or patterns
+ - The style, technique, or medium used (if discernible)
+
+ For screenshots or images containing text:
+ - The content and purpose of the text
+ - The layout, formatting, and organization of the information
+ - Any notable visual elements, such as logos, icons, or graphics
+ - The overall context or message conveyed by the screenshot
+
+ For graphs, charts, or data visualizations:
+ - The type of graph or chart (e.g., bar graph, line chart, pie chart)
+ - The variables being compared or analyzed
+ - Any trends, patterns, or outliers in the data
+ - The axis labels, scales, and units of measurement
+ - The title, legend, and any additional context provided
+
+ Be as specific and descriptive as possible while maintaining clarity and concision.`;
+};
+
+module.exports = createVisionPrompt;
diff --git a/api/app/clients/prompts/formatAgentMessages.spec.js b/api/app/clients/prompts/formatAgentMessages.spec.js
new file mode 100644
index 0000000000..1aee3edf71
--- /dev/null
+++ b/api/app/clients/prompts/formatAgentMessages.spec.js
@@ -0,0 +1,361 @@
+const { ToolMessage } = require('@langchain/core/messages');
+const { ContentTypes } = require('librechat-data-provider');
+const { HumanMessage, AIMessage, SystemMessage } = require('@langchain/core/messages');
+const { formatAgentMessages } = require('./formatMessages');
+
+describe('formatAgentMessages', () => {
+ it('should format simple user and AI messages', () => {
+ const payload = [
+ { role: 'user', content: 'Hello' },
+ { role: 'assistant', content: 'Hi there!' },
+ ];
+ const result = formatAgentMessages(payload);
+ expect(result).toHaveLength(2);
+ expect(result[0]).toBeInstanceOf(HumanMessage);
+ expect(result[1]).toBeInstanceOf(AIMessage);
+ });
+
+ it('should handle system messages', () => {
+ const payload = [{ role: 'system', content: 'You are a helpful assistant.' }];
+ const result = formatAgentMessages(payload);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBeInstanceOf(SystemMessage);
+ });
+
+ it('should format messages with content arrays', () => {
+ const payload = [
+ {
+ role: 'user',
+ content: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Hello' }],
+ },
+ ];
+ const result = formatAgentMessages(payload);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBeInstanceOf(HumanMessage);
+ });
+
+ it('should handle tool calls and create ToolMessages', () => {
+ const payload = [
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: ContentTypes.TEXT,
+ [ContentTypes.TEXT]: 'Let me check that for you.',
+ tool_call_ids: ['123'],
+ },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: {
+ id: '123',
+ name: 'search',
+ args: '{"query":"weather"}',
+ output: 'The weather is sunny.',
+ },
+ },
+ ],
+ },
+ ];
+ const result = formatAgentMessages(payload);
+ expect(result).toHaveLength(2);
+ expect(result[0]).toBeInstanceOf(AIMessage);
+ expect(result[1]).toBeInstanceOf(ToolMessage);
+ expect(result[0].tool_calls).toHaveLength(1);
+ expect(result[1].tool_call_id).toBe('123');
+ });
+
+ it('should handle multiple content parts in assistant messages', () => {
+ const payload = [
+ {
+ role: 'assistant',
+ content: [
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Part 1' },
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Part 2' },
+ ],
+ },
+ ];
+ const result = formatAgentMessages(payload);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBeInstanceOf(AIMessage);
+ expect(result[0].content).toHaveLength(2);
+ });
+
+ it('should throw an error for invalid tool call structure', () => {
+ const payload = [
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: {
+ id: '123',
+ name: 'search',
+ args: '{"query":"weather"}',
+ output: 'The weather is sunny.',
+ },
+ },
+ ],
+ },
+ ];
+ expect(() => formatAgentMessages(payload)).toThrow('Invalid tool call structure');
+ });
+
+ it('should handle tool calls with non-JSON args', () => {
+ const payload = [
+ {
+ role: 'assistant',
+ content: [
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Checking...', tool_call_ids: ['123'] },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: {
+ id: '123',
+ name: 'search',
+ args: 'non-json-string',
+ output: 'Result',
+ },
+ },
+ ],
+ },
+ ];
+ const result = formatAgentMessages(payload);
+ expect(result).toHaveLength(2);
+ expect(result[0].tool_calls[0].args).toStrictEqual({ input: 'non-json-string' });
+ });
+
+ it('should handle complex tool calls with multiple steps', () => {
+ const payload = [
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: ContentTypes.TEXT,
+ [ContentTypes.TEXT]: "I'll search for that information.",
+ tool_call_ids: ['search_1'],
+ },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: {
+ id: 'search_1',
+ name: 'search',
+ args: '{"query":"weather in New York"}',
+ output: 'The weather in New York is currently sunny with a temperature of 75°F.',
+ },
+ },
+ {
+ type: ContentTypes.TEXT,
+ [ContentTypes.TEXT]: "Now, I'll convert the temperature.",
+ tool_call_ids: ['convert_1'],
+ },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: {
+ id: 'convert_1',
+ name: 'convert_temperature',
+ args: '{"temperature": 75, "from": "F", "to": "C"}',
+ output: '23.89°C',
+ },
+ },
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: "Here's your answer." },
+ ],
+ },
+ ];
+
+ const result = formatAgentMessages(payload);
+
+ expect(result).toHaveLength(5);
+ expect(result[0]).toBeInstanceOf(AIMessage);
+ expect(result[1]).toBeInstanceOf(ToolMessage);
+ expect(result[2]).toBeInstanceOf(AIMessage);
+ expect(result[3]).toBeInstanceOf(ToolMessage);
+ expect(result[4]).toBeInstanceOf(AIMessage);
+
+ // Check first AIMessage
+ expect(result[0].content).toBe("I'll search for that information.");
+ expect(result[0].tool_calls).toHaveLength(1);
+ expect(result[0].tool_calls[0]).toEqual({
+ id: 'search_1',
+ name: 'search',
+ args: { query: 'weather in New York' },
+ });
+
+ // Check first ToolMessage
+ expect(result[1].tool_call_id).toBe('search_1');
+ expect(result[1].name).toBe('search');
+ expect(result[1].content).toBe(
+ 'The weather in New York is currently sunny with a temperature of 75°F.',
+ );
+
+ // Check second AIMessage
+ expect(result[2].content).toBe("Now, I'll convert the temperature.");
+ expect(result[2].tool_calls).toHaveLength(1);
+ expect(result[2].tool_calls[0]).toEqual({
+ id: 'convert_1',
+ name: 'convert_temperature',
+ args: { temperature: 75, from: 'F', to: 'C' },
+ });
+
+ // Check second ToolMessage
+ expect(result[3].tool_call_id).toBe('convert_1');
+ expect(result[3].name).toBe('convert_temperature');
+ expect(result[3].content).toBe('23.89°C');
+
+ // Check final AIMessage
+ expect(result[4].content).toStrictEqual([
+ { [ContentTypes.TEXT]: "Here's your answer.", type: ContentTypes.TEXT },
+ ]);
+ });
+
+ it.skip('should not produce two consecutive assistant messages and format content correctly', () => {
+ const payload = [
+ { role: 'user', content: 'Hello' },
+ {
+ role: 'assistant',
+ content: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Hi there!' }],
+ },
+ {
+ role: 'assistant',
+ content: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'How can I help you?' }],
+ },
+ { role: 'user', content: "What's the weather?" },
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: ContentTypes.TEXT,
+ [ContentTypes.TEXT]: 'Let me check that for you.',
+ tool_call_ids: ['weather_1'],
+ },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: {
+ id: 'weather_1',
+ name: 'check_weather',
+ args: '{"location":"New York"}',
+ output: 'Sunny, 75°F',
+ },
+ },
+ ],
+ },
+ {
+ role: 'assistant',
+ content: [
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: "Here's the weather information." },
+ ],
+ },
+ ];
+
+ const result = formatAgentMessages(payload);
+
+ // Check correct message count and types
+ expect(result).toHaveLength(6);
+ expect(result[0]).toBeInstanceOf(HumanMessage);
+ expect(result[1]).toBeInstanceOf(AIMessage);
+ expect(result[2]).toBeInstanceOf(HumanMessage);
+ expect(result[3]).toBeInstanceOf(AIMessage);
+ expect(result[4]).toBeInstanceOf(ToolMessage);
+ expect(result[5]).toBeInstanceOf(AIMessage);
+
+ // Check content of messages
+ expect(result[0].content).toStrictEqual([
+ { [ContentTypes.TEXT]: 'Hello', type: ContentTypes.TEXT },
+ ]);
+ expect(result[1].content).toStrictEqual([
+ { [ContentTypes.TEXT]: 'Hi there!', type: ContentTypes.TEXT },
+ { [ContentTypes.TEXT]: 'How can I help you?', type: ContentTypes.TEXT },
+ ]);
+ expect(result[2].content).toStrictEqual([
+ { [ContentTypes.TEXT]: "What's the weather?", type: ContentTypes.TEXT },
+ ]);
+ expect(result[3].content).toBe('Let me check that for you.');
+ expect(result[4].content).toBe('Sunny, 75°F');
+ expect(result[5].content).toStrictEqual([
+ { [ContentTypes.TEXT]: "Here's the weather information.", type: ContentTypes.TEXT },
+ ]);
+
+ // Check that there are no consecutive AIMessages
+ const messageTypes = result.map((message) => message.constructor);
+ for (let i = 0; i < messageTypes.length - 1; i++) {
+ expect(messageTypes[i] === AIMessage && messageTypes[i + 1] === AIMessage).toBe(false);
+ }
+
+ // Additional check to ensure the consecutive assistant messages were combined
+ expect(result[1].content).toHaveLength(2);
+ });
+
+ it('should skip THINK type content parts', () => {
+ const payload = [
+ {
+ role: 'assistant',
+ content: [
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Initial response' },
+ { type: ContentTypes.THINK, [ContentTypes.THINK]: 'Reasoning about the problem...' },
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Final answer' },
+ ],
+ },
+ ];
+
+ const result = formatAgentMessages(payload);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBeInstanceOf(AIMessage);
+ expect(result[0].content).toEqual('Initial response\nFinal answer');
+ });
+
+ it('should join TEXT content as string when THINK content type is present', () => {
+ const payload = [
+ {
+ role: 'assistant',
+ content: [
+ { type: ContentTypes.THINK, [ContentTypes.THINK]: 'Analyzing the problem...' },
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'First part of response' },
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Second part of response' },
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Final part of response' },
+ ],
+ },
+ ];
+
+ const result = formatAgentMessages(payload);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBeInstanceOf(AIMessage);
+ expect(typeof result[0].content).toBe('string');
+ expect(result[0].content).toBe(
+ 'First part of response\nSecond part of response\nFinal part of response',
+ );
+ expect(result[0].content).not.toContain('Analyzing the problem...');
+ });
+
+ it('should exclude ERROR type content parts', () => {
+ const payload = [
+ {
+ role: 'assistant',
+ content: [
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Hello there' },
+ {
+ type: ContentTypes.ERROR,
+ [ContentTypes.ERROR]:
+ 'An error occurred while processing the request: Something went wrong',
+ },
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Final answer' },
+ ],
+ },
+ ];
+
+ const result = formatAgentMessages(payload);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBeInstanceOf(AIMessage);
+ expect(result[0].content).toEqual([
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Hello there' },
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Final answer' },
+ ]);
+
+ // Make sure no error content exists in the result
+ const hasErrorContent = result[0].content.some(
+ (item) =>
+ item.type === ContentTypes.ERROR || JSON.stringify(item).includes('An error occurred'),
+ );
+ expect(hasErrorContent).toBe(false);
+ });
+});
diff --git a/api/app/clients/prompts/formatGoogleInputs.js b/api/app/clients/prompts/formatGoogleInputs.js
new file mode 100644
index 0000000000..c929df8b51
--- /dev/null
+++ b/api/app/clients/prompts/formatGoogleInputs.js
@@ -0,0 +1,42 @@
+/**
+ * Formats an object to match the struct_val, list_val, string_val, float_val, and int_val format.
+ *
+ * @param {Object} obj - The object to be formatted.
+ * @returns {Object} The formatted object.
+ *
+ * Handles different types:
+ * - Arrays are wrapped in list_val and each element is processed.
+ * - Objects are recursively processed.
+ * - Strings are wrapped in string_val.
+ * - Numbers are wrapped in float_val or int_val depending on whether they are floating-point or integers.
+ */
+function formatGoogleInputs(obj) {
+ const formattedObj = {};
+
+ for (const key in obj) {
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
+ const value = obj[key];
+
+ // Handle arrays
+ if (Array.isArray(value)) {
+ formattedObj[key] = { list_val: value.map((item) => formatGoogleInputs(item)) };
+ }
+ // Handle objects
+ else if (typeof value === 'object' && value !== null) {
+ formattedObj[key] = formatGoogleInputs(value);
+ }
+ // Handle numbers
+ else if (typeof value === 'number') {
+ formattedObj[key] = Number.isInteger(value) ? { int_val: value } : { float_val: value };
+ }
+ // Handle other types (e.g., strings)
+ else {
+ formattedObj[key] = { string_val: [value] };
+ }
+ }
+ }
+
+ return { struct_val: formattedObj };
+}
+
+module.exports = formatGoogleInputs;
diff --git a/api/app/clients/prompts/formatGoogleInputs.spec.js b/api/app/clients/prompts/formatGoogleInputs.spec.js
new file mode 100644
index 0000000000..8fef9dfb5f
--- /dev/null
+++ b/api/app/clients/prompts/formatGoogleInputs.spec.js
@@ -0,0 +1,274 @@
+const formatGoogleInputs = require('./formatGoogleInputs');
+
+describe('formatGoogleInputs', () => {
+ it('formats message correctly', () => {
+ const input = {
+ messages: [
+ {
+ content: 'hi',
+ author: 'user',
+ },
+ ],
+ context: 'context',
+ examples: [
+ {
+ input: {
+ author: 'user',
+ content: 'user input',
+ },
+ output: {
+ author: 'bot',
+ content: 'bot output',
+ },
+ },
+ ],
+ parameters: {
+ temperature: 0.2,
+ topP: 0.8,
+ topK: 40,
+ maxOutputTokens: 1024,
+ },
+ };
+
+ const expectedOutput = {
+ struct_val: {
+ messages: {
+ list_val: [
+ {
+ struct_val: {
+ content: {
+ string_val: ['hi'],
+ },
+ author: {
+ string_val: ['user'],
+ },
+ },
+ },
+ ],
+ },
+ context: {
+ string_val: ['context'],
+ },
+ examples: {
+ list_val: [
+ {
+ struct_val: {
+ input: {
+ struct_val: {
+ author: {
+ string_val: ['user'],
+ },
+ content: {
+ string_val: ['user input'],
+ },
+ },
+ },
+ output: {
+ struct_val: {
+ author: {
+ string_val: ['bot'],
+ },
+ content: {
+ string_val: ['bot output'],
+ },
+ },
+ },
+ },
+ },
+ ],
+ },
+ parameters: {
+ struct_val: {
+ temperature: {
+ float_val: 0.2,
+ },
+ topP: {
+ float_val: 0.8,
+ },
+ topK: {
+ int_val: 40,
+ },
+ maxOutputTokens: {
+ int_val: 1024,
+ },
+ },
+ },
+ },
+ };
+
+ const result = formatGoogleInputs(input);
+ expect(JSON.stringify(result)).toEqual(JSON.stringify(expectedOutput));
+ });
+
+ it('formats real payload parts', () => {
+ const input = {
+ instances: [
+ {
+ context: 'context',
+ examples: [
+ {
+ input: {
+ author: 'user',
+ content: 'user input',
+ },
+ output: {
+ author: 'bot',
+ content: 'user output',
+ },
+ },
+ ],
+ messages: [
+ {
+ author: 'user',
+ content: 'hi',
+ },
+ ],
+ },
+ ],
+ parameters: {
+ candidateCount: 1,
+ maxOutputTokens: 1024,
+ temperature: 0.2,
+ topP: 0.8,
+ topK: 40,
+ },
+ };
+ const expectedOutput = {
+ struct_val: {
+ instances: {
+ list_val: [
+ {
+ struct_val: {
+ context: { string_val: ['context'] },
+ examples: {
+ list_val: [
+ {
+ struct_val: {
+ input: {
+ struct_val: {
+ author: { string_val: ['user'] },
+ content: { string_val: ['user input'] },
+ },
+ },
+ output: {
+ struct_val: {
+ author: { string_val: ['bot'] },
+ content: { string_val: ['user output'] },
+ },
+ },
+ },
+ },
+ ],
+ },
+ messages: {
+ list_val: [
+ {
+ struct_val: {
+ author: { string_val: ['user'] },
+ content: { string_val: ['hi'] },
+ },
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ parameters: {
+ struct_val: {
+ candidateCount: { int_val: 1 },
+ maxOutputTokens: { int_val: 1024 },
+ temperature: { float_val: 0.2 },
+ topP: { float_val: 0.8 },
+ topK: { int_val: 40 },
+ },
+ },
+ },
+ };
+
+ const result = formatGoogleInputs(input);
+ expect(JSON.stringify(result)).toEqual(JSON.stringify(expectedOutput));
+ });
+
+ it('helps create valid payload parts', () => {
+ const instances = {
+ context: 'context',
+ examples: [
+ {
+ input: {
+ author: 'user',
+ content: 'user input',
+ },
+ output: {
+ author: 'bot',
+ content: 'user output',
+ },
+ },
+ ],
+ messages: [
+ {
+ author: 'user',
+ content: 'hi',
+ },
+ ],
+ };
+
+ const expectedInstances = {
+ struct_val: {
+ context: { string_val: ['context'] },
+ examples: {
+ list_val: [
+ {
+ struct_val: {
+ input: {
+ struct_val: {
+ author: { string_val: ['user'] },
+ content: { string_val: ['user input'] },
+ },
+ },
+ output: {
+ struct_val: {
+ author: { string_val: ['bot'] },
+ content: { string_val: ['user output'] },
+ },
+ },
+ },
+ },
+ ],
+ },
+ messages: {
+ list_val: [
+ {
+ struct_val: {
+ author: { string_val: ['user'] },
+ content: { string_val: ['hi'] },
+ },
+ },
+ ],
+ },
+ },
+ };
+
+ const parameters = {
+ candidateCount: 1,
+ maxOutputTokens: 1024,
+ temperature: 0.2,
+ topP: 0.8,
+ topK: 40,
+ };
+ const expectedParameters = {
+ struct_val: {
+ candidateCount: { int_val: 1 },
+ maxOutputTokens: { int_val: 1024 },
+ temperature: { float_val: 0.2 },
+ topP: { float_val: 0.8 },
+ topK: { int_val: 40 },
+ },
+ };
+
+ const instancesResult = formatGoogleInputs(instances);
+ const parametersResult = formatGoogleInputs(parameters);
+ expect(JSON.stringify(instancesResult)).toEqual(JSON.stringify(expectedInstances));
+ expect(JSON.stringify(parametersResult)).toEqual(JSON.stringify(expectedParameters));
+ });
+});
diff --git a/api/app/clients/prompts/formatMessages.js b/api/app/clients/prompts/formatMessages.js
new file mode 100644
index 0000000000..376178b6be
--- /dev/null
+++ b/api/app/clients/prompts/formatMessages.js
@@ -0,0 +1,245 @@
+const { ToolMessage } = require('@langchain/core/messages');
+const { EModelEndpoint, ContentTypes } = require('librechat-data-provider');
+const { HumanMessage, AIMessage, SystemMessage } = require('@langchain/core/messages');
+
+/**
+ * Formats a message to OpenAI Vision API payload format.
+ *
+ * @param {Object} params - The parameters for formatting.
+ * @param {Object} params.message - The message object to format.
+ * @param {string} [params.message.role] - The role of the message sender (must be 'user').
+ * @param {string} [params.message.content] - The text content of the message.
+ * @param {EModelEndpoint} [params.endpoint] - Identifier for specific endpoint handling
+ * @param {Array} [params.image_urls] - The image_urls to attach to the message.
+ * @returns {(Object)} - The formatted message.
+ */
+const formatVisionMessage = ({ message, image_urls, endpoint }) => {
+ if (endpoint === EModelEndpoint.anthropic) {
+ message.content = [...image_urls, { type: ContentTypes.TEXT, text: message.content }];
+ return message;
+ }
+
+ message.content = [{ type: ContentTypes.TEXT, text: message.content }, ...image_urls];
+
+ return message;
+};
+
+/**
+ * Formats a message to OpenAI payload format based on the provided options.
+ *
+ * @param {Object} params - The parameters for formatting.
+ * @param {Object} params.message - The message object to format.
+ * @param {string} [params.message.role] - The role of the message sender (e.g., 'user', 'assistant').
+ * @param {string} [params.message._name] - The name associated with the message.
+ * @param {string} [params.message.sender] - The sender of the message.
+ * @param {string} [params.message.text] - The text content of the message.
+ * @param {string} [params.message.content] - The content of the message.
+ * @param {Array} [params.message.image_urls] - The image_urls attached to the message for Vision API.
+ * @param {string} [params.userName] - The name of the user.
+ * @param {string} [params.assistantName] - The name of the assistant.
+ * @param {string} [params.endpoint] - Identifier for specific endpoint handling
+ * @param {boolean} [params.langChain=false] - Whether to return a LangChain message object.
+ * @returns {(Object|HumanMessage|AIMessage|SystemMessage)} - The formatted message.
+ */
+const formatMessage = ({ message, userName, assistantName, endpoint, langChain = false }) => {
+ let { role: _role, _name, sender, text, content: _content, lc_id } = message;
+ if (lc_id && lc_id[2] && !langChain) {
+ const roleMapping = {
+ SystemMessage: 'system',
+ HumanMessage: 'user',
+ AIMessage: 'assistant',
+ };
+ _role = roleMapping[lc_id[2]];
+ }
+ const role = _role ?? (sender && sender?.toLowerCase() === 'user' ? 'user' : 'assistant');
+ const content = _content ?? text ?? '';
+ const formattedMessage = {
+ role,
+ content,
+ };
+
+ const { image_urls } = message;
+ if (Array.isArray(image_urls) && image_urls.length > 0 && role === 'user') {
+ return formatVisionMessage({
+ message: formattedMessage,
+ image_urls: message.image_urls,
+ endpoint,
+ });
+ }
+
+ if (_name) {
+ formattedMessage.name = _name;
+ }
+
+ if (userName && formattedMessage.role === 'user') {
+ formattedMessage.name = userName;
+ }
+
+ if (assistantName && formattedMessage.role === 'assistant') {
+ formattedMessage.name = assistantName;
+ }
+
+ if (formattedMessage.name) {
+ // Conform to API regex: ^[a-zA-Z0-9_-]{1,64}$
+ // https://community.openai.com/t/the-format-of-the-name-field-in-the-documentation-is-incorrect/175684/2
+ formattedMessage.name = formattedMessage.name.replace(/[^a-zA-Z0-9_-]/g, '_');
+
+ if (formattedMessage.name.length > 64) {
+ formattedMessage.name = formattedMessage.name.substring(0, 64);
+ }
+ }
+
+ if (!langChain) {
+ return formattedMessage;
+ }
+
+ if (role === 'user') {
+ return new HumanMessage(formattedMessage);
+ } else if (role === 'assistant') {
+ return new AIMessage(formattedMessage);
+ } else {
+ return new SystemMessage(formattedMessage);
+ }
+};
+
+/**
+ * Formats an array of messages for LangChain.
+ *
+ * @param {Array} messages - The array of messages to format.
+ * @param {Object} formatOptions - The options for formatting each message.
+ * @param {string} [formatOptions.userName] - The name of the user.
+ * @param {string} [formatOptions.assistantName] - The name of the assistant.
+ * @returns {Array<(HumanMessage|AIMessage|SystemMessage)>} - The array of formatted LangChain messages.
+ */
+const formatLangChainMessages = (messages, formatOptions) =>
+ messages.map((msg) => formatMessage({ ...formatOptions, message: msg, langChain: true }));
+
+/**
+ * Formats a LangChain message object by merging properties from `lc_kwargs` or `kwargs` and `additional_kwargs`.
+ *
+ * @param {Object} message - The message object to format.
+ * @param {Object} [message.lc_kwargs] - Contains properties to be merged. Either this or `message.kwargs` should be provided.
+ * @param {Object} [message.kwargs] - Contains properties to be merged. Either this or `message.lc_kwargs` should be provided.
+ * @param {Object} [message.kwargs.additional_kwargs] - Additional properties to be merged.
+ *
+ * @returns {Object} The formatted LangChain message.
+ */
+const formatFromLangChain = (message) => {
+ const { additional_kwargs, ...message_kwargs } = message.lc_kwargs ?? message.kwargs;
+ return {
+ ...message_kwargs,
+ ...additional_kwargs,
+ };
+};
+
+/**
+ * Formats an array of messages for LangChain, handling tool calls and creating ToolMessage instances.
+ *
+ * @param {Array>} payload - The array of messages to format.
+ * @returns {Array<(HumanMessage|AIMessage|SystemMessage|ToolMessage)>} - The array of formatted LangChain messages, including ToolMessages for tool calls.
+ */
+const formatAgentMessages = (payload) => {
+ const messages = [];
+
+ for (const message of payload) {
+ if (typeof message.content === 'string') {
+ message.content = [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: message.content }];
+ }
+ if (message.role !== 'assistant') {
+ messages.push(formatMessage({ message, langChain: true }));
+ continue;
+ }
+
+ let currentContent = [];
+ let lastAIMessage = null;
+
+ let hasReasoning = false;
+ for (const part of message.content) {
+ if (part.type === ContentTypes.TEXT && part.tool_call_ids) {
+ /*
+ If there's pending content, it needs to be aggregated as a single string to prepare for tool calls.
+ For Anthropic models, the "tool_calls" field on a message is only respected if content is a string.
+ */
+ if (currentContent.length > 0) {
+ let content = currentContent.reduce((acc, curr) => {
+ if (curr.type === ContentTypes.TEXT) {
+ return `${acc}${curr[ContentTypes.TEXT]}\n`;
+ }
+ return acc;
+ }, '');
+ content = `${content}\n${part[ContentTypes.TEXT] ?? ''}`.trim();
+ lastAIMessage = new AIMessage({ content });
+ messages.push(lastAIMessage);
+ currentContent = [];
+ continue;
+ }
+
+ // Create a new AIMessage with this text and prepare for tool calls
+ lastAIMessage = new AIMessage({
+ content: part.text || '',
+ });
+
+ messages.push(lastAIMessage);
+ } else if (part.type === ContentTypes.TOOL_CALL) {
+ if (!lastAIMessage) {
+ throw new Error('Invalid tool call structure: No preceding AIMessage with tool_call_ids');
+ }
+
+ // Note: `tool_calls` list is defined when constructed by `AIMessage` class, and outputs should be excluded from it
+ const { output, args: _args, ...tool_call } = part.tool_call;
+ // TODO: investigate; args as dictionary may need to be provider-or-tool-specific
+ let args = _args;
+ try {
+ args = JSON.parse(_args);
+ } catch (e) {
+ if (typeof _args === 'string') {
+ args = { input: _args };
+ }
+ }
+
+ tool_call.args = args;
+ lastAIMessage.tool_calls.push(tool_call);
+
+ // Add the corresponding ToolMessage
+ messages.push(
+ new ToolMessage({
+ tool_call_id: tool_call.id,
+ name: tool_call.name,
+ content: output || '',
+ }),
+ );
+ } else if (part.type === ContentTypes.THINK) {
+ hasReasoning = true;
+ continue;
+ } else if (part.type === ContentTypes.ERROR || part.type === ContentTypes.AGENT_UPDATE) {
+ continue;
+ } else {
+ currentContent.push(part);
+ }
+ }
+
+ if (hasReasoning) {
+ currentContent = currentContent
+ .reduce((acc, curr) => {
+ if (curr.type === ContentTypes.TEXT) {
+ return `${acc}${curr[ContentTypes.TEXT]}\n`;
+ }
+ return acc;
+ }, '')
+ .trim();
+ }
+
+ if (currentContent.length > 0) {
+ messages.push(new AIMessage({ content: currentContent }));
+ }
+ }
+
+ return messages;
+};
+
+module.exports = {
+ formatMessage,
+ formatFromLangChain,
+ formatAgentMessages,
+ formatLangChainMessages,
+};
diff --git a/api/app/clients/prompts/formatMessages.spec.js b/api/app/clients/prompts/formatMessages.spec.js
new file mode 100644
index 0000000000..97e40b0caa
--- /dev/null
+++ b/api/app/clients/prompts/formatMessages.spec.js
@@ -0,0 +1,276 @@
+const { Constants } = require('librechat-data-provider');
+const { HumanMessage, AIMessage, SystemMessage } = require('@langchain/core/messages');
+const { formatMessage, formatLangChainMessages, formatFromLangChain } = require('./formatMessages');
+
+describe('formatMessage', () => {
+ it('formats user message', () => {
+ const input = {
+ message: {
+ sender: 'user',
+ text: 'Hello',
+ },
+ userName: 'John',
+ };
+ const result = formatMessage(input);
+ expect(result).toEqual({
+ role: 'user',
+ content: 'Hello',
+ name: 'John',
+ });
+ });
+
+ it('sanitizes the name by replacing invalid characters (per OpenAI)', () => {
+ const input = {
+ message: {
+ sender: 'user',
+ text: 'Hello',
+ },
+ userName: ' John$Doe@Example! ',
+ };
+ const result = formatMessage(input);
+ expect(result).toEqual({
+ role: 'user',
+ content: 'Hello',
+ name: '_John_Doe_Example__',
+ });
+ });
+
+ it('trims the name to a maximum length of 64 characters', () => {
+ const longName = 'a'.repeat(100);
+ const input = {
+ message: {
+ sender: 'user',
+ text: 'Hello',
+ },
+ userName: longName,
+ };
+ const result = formatMessage(input);
+ expect(result.name.length).toBe(64);
+ expect(result.name).toBe('a'.repeat(64));
+ });
+
+ it('formats a realistic user message', () => {
+ const input = {
+ message: {
+ _id: '6512cdfb92cbf69fea615331',
+ messageId: 'b620bf73-c5c3-4a38-b724-76886aac24c4',
+ __v: 0,
+ conversationId: '5c23d24f-941f-4aab-85df-127b596c8aa5',
+ createdAt: Date.now(),
+ error: false,
+ finish_reason: null,
+ isCreatedByUser: true,
+ model: null,
+ parentMessageId: Constants.NO_PARENT,
+ sender: 'User',
+ text: 'hi',
+ tokenCount: 5,
+ unfinished: false,
+ updatedAt: Date.now(),
+ user: '6512cdf475f05c86d44c31d2',
+ },
+ userName: 'John',
+ };
+ const result = formatMessage(input);
+ expect(result).toEqual({
+ role: 'user',
+ content: 'hi',
+ name: 'John',
+ });
+ });
+
+ it('formats assistant message', () => {
+ const input = {
+ message: {
+ sender: 'assistant',
+ text: 'Hi there',
+ },
+ assistantName: 'Assistant',
+ };
+ const result = formatMessage(input);
+ expect(result).toEqual({
+ role: 'assistant',
+ content: 'Hi there',
+ name: 'Assistant',
+ });
+ });
+
+ it('formats system message', () => {
+ const input = {
+ message: {
+ role: 'system',
+ text: 'Hi there',
+ },
+ };
+ const result = formatMessage(input);
+ expect(result).toEqual({
+ role: 'system',
+ content: 'Hi there',
+ });
+ });
+
+ it('formats user message with langChain', () => {
+ const input = {
+ message: {
+ sender: 'user',
+ text: 'Hello',
+ },
+ userName: 'John',
+ langChain: true,
+ };
+ const result = formatMessage(input);
+ expect(result).toBeInstanceOf(HumanMessage);
+ expect(result.lc_kwargs.content).toEqual(input.message.text);
+ expect(result.lc_kwargs.name).toEqual(input.userName);
+ });
+
+ it('formats assistant message with langChain', () => {
+ const input = {
+ message: {
+ sender: 'assistant',
+ text: 'Hi there',
+ },
+ assistantName: 'Assistant',
+ langChain: true,
+ };
+ const result = formatMessage(input);
+ expect(result).toBeInstanceOf(AIMessage);
+ expect(result.lc_kwargs.content).toEqual(input.message.text);
+ expect(result.lc_kwargs.name).toEqual(input.assistantName);
+ });
+
+ it('formats system message with langChain', () => {
+ const input = {
+ message: {
+ role: 'system',
+ text: 'This is a system message.',
+ },
+ langChain: true,
+ };
+ const result = formatMessage(input);
+ expect(result).toBeInstanceOf(SystemMessage);
+ expect(result.lc_kwargs.content).toEqual(input.message.text);
+ });
+
+ it('formats langChain messages into OpenAI payload format', () => {
+ const human = {
+ message: new HumanMessage({
+ content: 'Hello',
+ }),
+ };
+ const system = {
+ message: new SystemMessage({
+ content: 'Hello',
+ }),
+ };
+ const ai = {
+ message: new AIMessage({
+ content: 'Hello',
+ }),
+ };
+ const humanResult = formatMessage(human);
+ const systemResult = formatMessage(system);
+ const aiResult = formatMessage(ai);
+ expect(humanResult).toEqual({
+ role: 'user',
+ content: 'Hello',
+ });
+ expect(systemResult).toEqual({
+ role: 'system',
+ content: 'Hello',
+ });
+ expect(aiResult).toEqual({
+ role: 'assistant',
+ content: 'Hello',
+ });
+ });
+});
+
+describe('formatLangChainMessages', () => {
+ it('formats an array of messages for LangChain', () => {
+ const messages = [
+ {
+ role: 'system',
+ content: 'This is a system message',
+ },
+ {
+ sender: 'user',
+ text: 'Hello',
+ },
+ {
+ sender: 'assistant',
+ text: 'Hi there',
+ },
+ ];
+ const formatOptions = {
+ userName: 'John',
+ assistantName: 'Assistant',
+ };
+ const result = formatLangChainMessages(messages, formatOptions);
+ expect(result).toHaveLength(3);
+ expect(result[0]).toBeInstanceOf(SystemMessage);
+ expect(result[1]).toBeInstanceOf(HumanMessage);
+ expect(result[2]).toBeInstanceOf(AIMessage);
+
+ expect(result[0].lc_kwargs.content).toEqual(messages[0].content);
+ expect(result[1].lc_kwargs.content).toEqual(messages[1].text);
+ expect(result[2].lc_kwargs.content).toEqual(messages[2].text);
+
+ expect(result[1].lc_kwargs.name).toEqual(formatOptions.userName);
+ expect(result[2].lc_kwargs.name).toEqual(formatOptions.assistantName);
+ });
+
+ describe('formatFromLangChain', () => {
+ it('should merge kwargs and additional_kwargs', () => {
+ const message = {
+ kwargs: {
+ content: 'some content',
+ name: 'dan',
+ additional_kwargs: {
+ function_call: {
+ name: 'dall-e',
+ arguments: '{\n "input": "Subject: hedgehog, Style: cute"\n}',
+ },
+ },
+ },
+ };
+
+ const expected = {
+ content: 'some content',
+ name: 'dan',
+ function_call: {
+ name: 'dall-e',
+ arguments: '{\n "input": "Subject: hedgehog, Style: cute"\n}',
+ },
+ };
+
+ expect(formatFromLangChain(message)).toEqual(expected);
+ });
+
+ it('should handle messages without additional_kwargs', () => {
+ const message = {
+ kwargs: {
+ content: 'some content',
+ name: 'dan',
+ },
+ };
+
+ const expected = {
+ content: 'some content',
+ name: 'dan',
+ };
+
+ expect(formatFromLangChain(message)).toEqual(expected);
+ });
+
+ it('should handle empty messages', () => {
+ const message = {
+ kwargs: {},
+ };
+
+ const expected = {};
+
+ expect(formatFromLangChain(message)).toEqual(expected);
+ });
+ });
+});
diff --git a/api/app/clients/prompts/index.js b/api/app/clients/prompts/index.js
new file mode 100644
index 0000000000..ba4859efe3
--- /dev/null
+++ b/api/app/clients/prompts/index.js
@@ -0,0 +1,13 @@
+const formatMessages = require('./formatMessages');
+const summaryPrompts = require('./summaryPrompts');
+const truncate = require('./truncate');
+const createVisionPrompt = require('./createVisionPrompt');
+const createContextHandlers = require('./createContextHandlers');
+
+module.exports = {
+ ...formatMessages,
+ ...summaryPrompts,
+ ...truncate,
+ createVisionPrompt,
+ createContextHandlers,
+};
diff --git a/api/app/clients/prompts/shadcn-docs/components.js b/api/app/clients/prompts/shadcn-docs/components.js
new file mode 100644
index 0000000000..b67c47d50f
--- /dev/null
+++ b/api/app/clients/prompts/shadcn-docs/components.js
@@ -0,0 +1,495 @@
+// Essential Components
+const essentialComponents = {
+ avatar: {
+ componentName: 'Avatar',
+ importDocs: 'import { Avatar, AvatarFallback, AvatarImage } from "/components/ui/avatar"',
+ usageDocs: `
+
+
+ CN
+ `,
+ },
+ button: {
+ componentName: 'Button',
+ importDocs: 'import { Button } from "/components/ui/button"',
+ usageDocs: `
+Button `,
+ },
+ card: {
+ componentName: 'Card',
+ importDocs: `
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "/components/ui/card"`,
+ usageDocs: `
+
+
+ Card Title
+ Card Description
+
+
+ Card Content
+
+
+ Card Footer
+
+ `,
+ },
+ checkbox: {
+ componentName: 'Checkbox',
+ importDocs: 'import { Checkbox } from "/components/ui/checkbox"',
+ usageDocs: ' ',
+ },
+ input: {
+ componentName: 'Input',
+ importDocs: 'import { Input } from "/components/ui/input"',
+ usageDocs: ' ',
+ },
+ label: {
+ componentName: 'Label',
+ importDocs: 'import { Label } from "/components/ui/label"',
+ usageDocs: 'Your email address ',
+ },
+ radioGroup: {
+ componentName: 'RadioGroup',
+ importDocs: `
+import { Label } from "/components/ui/label"
+import { RadioGroup, RadioGroupItem } from "/components/ui/radio-group"`,
+ usageDocs: `
+
+
+
+ Option One
+
+
+
+ Option Two
+
+ `,
+ },
+ select: {
+ componentName: 'Select',
+ importDocs: `
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "/components/ui/select"`,
+ usageDocs: `
+
+
+
+
+
+ Light
+ Dark
+ System
+
+ `,
+ },
+ textarea: {
+ componentName: 'Textarea',
+ importDocs: 'import { Textarea } from "/components/ui/textarea"',
+ usageDocs: '',
+ },
+};
+
+// Extra Components
+const extraComponents = {
+ accordion: {
+ componentName: 'Accordion',
+ importDocs: `
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "/components/ui/accordion"`,
+ usageDocs: `
+
+
+ Is it accessible?
+
+ Yes. It adheres to the WAI-ARIA design pattern.
+
+
+ `,
+ },
+ alertDialog: {
+ componentName: 'AlertDialog',
+ importDocs: `
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "/components/ui/alert-dialog"`,
+ usageDocs: `
+
+ Open
+
+
+ Are you absolutely sure?
+
+ This action cannot be undone.
+
+
+
+ Cancel
+ Continue
+
+
+ `,
+ },
+ alert: {
+ componentName: 'Alert',
+ importDocs: `
+import {
+ Alert,
+ AlertDescription,
+ AlertTitle,
+} from "/components/ui/alert"`,
+ usageDocs: `
+
+ Heads up!
+
+ You can add components to your app using the cli.
+
+ `,
+ },
+ aspectRatio: {
+ componentName: 'AspectRatio',
+ importDocs: 'import { AspectRatio } from "/components/ui/aspect-ratio"',
+ usageDocs: `
+
+
+ `,
+ },
+ badge: {
+ componentName: 'Badge',
+ importDocs: 'import { Badge } from "/components/ui/badge"',
+ usageDocs: 'Badge ',
+ },
+ calendar: {
+ componentName: 'Calendar',
+ importDocs: 'import { Calendar } from "/components/ui/calendar"',
+ usageDocs: ' ',
+ },
+ carousel: {
+ componentName: 'Carousel',
+ importDocs: `
+import {
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselNext,
+ CarouselPrevious,
+} from "/components/ui/carousel"`,
+ usageDocs: `
+
+
+ ...
+ ...
+ ...
+
+
+
+ `,
+ },
+ collapsible: {
+ componentName: 'Collapsible',
+ importDocs: `
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "/components/ui/collapsible"`,
+ usageDocs: `
+
+ Can I use this in my project?
+
+ Yes. Free to use for personal and commercial projects. No attribution required.
+
+ `,
+ },
+ dialog: {
+ componentName: 'Dialog',
+ importDocs: `
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "/components/ui/dialog"`,
+ usageDocs: `
+
+ Open
+
+
+ Are you sure absolutely sure?
+
+ This action cannot be undone.
+
+
+
+ `,
+ },
+ dropdownMenu: {
+ componentName: 'DropdownMenu',
+ importDocs: `
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "/components/ui/dropdown-menu"`,
+ usageDocs: `
+
+ Open
+
+ My Account
+
+ Profile
+ Billing
+ Team
+ Subscription
+
+ `,
+ },
+ menubar: {
+ componentName: 'Menubar',
+ importDocs: `
+import {
+ Menubar,
+ MenubarContent,
+ MenubarItem,
+ MenubarMenu,
+ MenubarSeparator,
+ MenubarShortcut,
+ MenubarTrigger,
+} from "/components/ui/menubar"`,
+ usageDocs: `
+
+
+ File
+
+
+ New Tab ⌘T
+
+ New Window
+
+ Share
+
+ Print
+
+
+ `,
+ },
+ navigationMenu: {
+ componentName: 'NavigationMenu',
+ importDocs: `
+import {
+ NavigationMenu,
+ NavigationMenuContent,
+ NavigationMenuItem,
+ NavigationMenuLink,
+ NavigationMenuList,
+ NavigationMenuTrigger,
+ navigationMenuTriggerStyle,
+} from "/components/ui/navigation-menu"`,
+ usageDocs: `
+
+
+
+ Item One
+
+ Link
+
+
+
+ `,
+ },
+ popover: {
+ componentName: 'Popover',
+ importDocs: `
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "/components/ui/popover"`,
+ usageDocs: `
+
+ Open
+ Place content for the popover here.
+ `,
+ },
+ progress: {
+ componentName: 'Progress',
+ importDocs: 'import { Progress } from "/components/ui/progress"',
+ usageDocs: ' ',
+ },
+ separator: {
+ componentName: 'Separator',
+ importDocs: 'import { Separator } from "/components/ui/separator"',
+ usageDocs: ' ',
+ },
+ sheet: {
+ componentName: 'Sheet',
+ importDocs: `
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "/components/ui/sheet"`,
+ usageDocs: `
+
+ Open
+
+
+ Are you sure absolutely sure?
+
+ This action cannot be undone.
+
+
+
+ `,
+ },
+ skeleton: {
+ componentName: 'Skeleton',
+ importDocs: 'import { Skeleton } from "/components/ui/skeleton"',
+ usageDocs: ' ',
+ },
+ slider: {
+ componentName: 'Slider',
+ importDocs: 'import { Slider } from "/components/ui/slider"',
+ usageDocs: ' ',
+ },
+ switch: {
+ componentName: 'Switch',
+ importDocs: 'import { Switch } from "/components/ui/switch"',
+ usageDocs: ' ',
+ },
+ table: {
+ componentName: 'Table',
+ importDocs: `
+import {
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "/components/ui/table"`,
+ usageDocs: `
+
+ A list of your recent invoices.
+
+
+ Invoice
+ Status
+ Method
+ Amount
+
+
+
+
+ INV001
+ Paid
+ Credit Card
+ $250.00
+
+
+
`,
+ },
+ tabs: {
+ componentName: 'Tabs',
+ importDocs: `
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "/components/ui/tabs"`,
+ usageDocs: `
+
+
+ Account
+ Password
+
+ Make changes to your account here.
+ Change your password here.
+ `,
+ },
+ toast: {
+ componentName: 'Toast',
+ importDocs: `
+import { useToast } from "/components/ui/use-toast"
+import { Button } from "/components/ui/button"`,
+ usageDocs: `
+export function ToastDemo() {
+ const { toast } = useToast()
+ return (
+ {
+ toast({
+ title: "Scheduled: Catch up",
+ description: "Friday, February 10, 2023 at 5:57 PM",
+ })
+ }}
+ >
+ Show Toast
+
+ )
+}`,
+ },
+ toggle: {
+ componentName: 'Toggle',
+ importDocs: 'import { Toggle } from "/components/ui/toggle"',
+ usageDocs: 'Toggle ',
+ },
+ tooltip: {
+ componentName: 'Tooltip',
+ importDocs: `
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "/components/ui/tooltip"`,
+ usageDocs: `
+
+
+ Hover
+
+ Add to library
+
+
+ `,
+ },
+};
+
+const components = Object.assign({}, essentialComponents, extraComponents);
+
+module.exports = {
+ components,
+};
diff --git a/api/app/clients/prompts/shadcn-docs/generate.js b/api/app/clients/prompts/shadcn-docs/generate.js
new file mode 100644
index 0000000000..6a4cf8c7e3
--- /dev/null
+++ b/api/app/clients/prompts/shadcn-docs/generate.js
@@ -0,0 +1,50 @@
+const dedent = require('dedent');
+
+/**
+ * Generate system prompt for AI-assisted React component creation
+ * @param {Object} options - Configuration options
+ * @param {Object} options.components - Documentation for shadcn components
+ * @param {boolean} [options.useXML=false] - Whether to use XML-style formatting for component instructions
+ * @returns {string} The generated system prompt
+ */
+function generateShadcnPrompt(options) {
+ const { components, useXML = false } = options;
+
+ let systemPrompt = dedent`
+ ## Additional Artifact Instructions for React Components: "application/vnd.react"
+
+ There are some prestyled components (primitives) available for use. Please use your best judgement to use any of these components if the app calls for one.
+
+ Here are the components that are available, along with how to import them, and how to use them:
+
+ ${Object.values(components)
+ .map((component) => {
+ if (useXML) {
+ return dedent`
+
+ ${component.componentName}
+ ${component.importDocs}
+ ${component.usageDocs}
+
+ `;
+ } else {
+ return dedent`
+ # ${component.componentName}
+
+ ## Import Instructions
+ ${component.importDocs}
+
+ ## Usage Instructions
+ ${component.usageDocs}
+ `;
+ }
+ })
+ .join('\n\n')}
+ `;
+
+ return systemPrompt;
+}
+
+module.exports = {
+ generateShadcnPrompt,
+};
diff --git a/api/app/clients/prompts/summaryPrompts.js b/api/app/clients/prompts/summaryPrompts.js
new file mode 100644
index 0000000000..4962e2b64b
--- /dev/null
+++ b/api/app/clients/prompts/summaryPrompts.js
@@ -0,0 +1,53 @@
+const { PromptTemplate } = require('@langchain/core/prompts');
+/*
+ * Without `{summary}` and `{new_lines}`, token count is 98
+ * We are counting this towards the max context tokens for summaries, +3 for the assistant label (101)
+ * If this prompt changes, use https://tiktokenizer.vercel.app/ to count the tokens
+ */
+const _DEFAULT_SUMMARIZER_TEMPLATE = `Summarize the conversation by integrating new lines into the current summary.
+
+EXAMPLE:
+Current summary:
+The human inquires about the AI's view on artificial intelligence. The AI believes it's beneficial.
+
+New lines:
+Human: Why is it beneficial?
+AI: It helps humans achieve their potential.
+
+New summary:
+The human inquires about the AI's view on artificial intelligence. The AI believes it's beneficial because it helps humans achieve their potential.
+
+Current summary:
+{summary}
+
+New lines:
+{new_lines}
+
+New summary:`;
+
+const SUMMARY_PROMPT = new PromptTemplate({
+ inputVariables: ['summary', 'new_lines'],
+ template: _DEFAULT_SUMMARIZER_TEMPLATE,
+});
+
+/*
+ * Without `{new_lines}`, token count is 27
+ * We are counting this towards the max context tokens for summaries, rounded up to 30
+ * If this prompt changes, use https://tiktokenizer.vercel.app/ to count the tokens
+ */
+const _CUT_OFF_SUMMARIZER = `The following text is cut-off:
+{new_lines}
+
+Summarize the content as best as you can, noting that it was cut-off.
+
+Summary:`;
+
+const CUT_OFF_PROMPT = new PromptTemplate({
+ inputVariables: ['new_lines'],
+ template: _CUT_OFF_SUMMARIZER,
+});
+
+module.exports = {
+ SUMMARY_PROMPT,
+ CUT_OFF_PROMPT,
+};
diff --git a/api/app/clients/prompts/truncate.js b/api/app/clients/prompts/truncate.js
new file mode 100644
index 0000000000..564b39efeb
--- /dev/null
+++ b/api/app/clients/prompts/truncate.js
@@ -0,0 +1,115 @@
+const MAX_CHAR = 255;
+
+/**
+ * Truncates a given text to a specified maximum length, appending ellipsis and a notification
+ * if the original text exceeds the maximum length.
+ *
+ * @param {string} text - The text to be truncated.
+ * @param {number} [maxLength=MAX_CHAR] - The maximum length of the text after truncation. Defaults to MAX_CHAR.
+ * @returns {string} The truncated text if the original text length exceeds maxLength, otherwise returns the original text.
+ */
+function truncateText(text, maxLength = MAX_CHAR) {
+ if (text.length > maxLength) {
+ return `${text.slice(0, maxLength)}... [text truncated for brevity]`;
+ }
+ return text;
+}
+
+/**
+ * Truncates a given text to a specified maximum length by showing the first half and the last half of the text,
+ * separated by ellipsis. This method ensures the output does not exceed the maximum length, including the addition
+ * of ellipsis and notification if the original text exceeds the maximum length.
+ *
+ * @param {string} text - The text to be truncated.
+ * @param {number} [maxLength=MAX_CHAR] - The maximum length of the output text after truncation. Defaults to MAX_CHAR.
+ * @returns {string} The truncated text showing the first half and the last half, or the original text if it does not exceed maxLength.
+ */
+function smartTruncateText(text, maxLength = MAX_CHAR) {
+ const ellipsis = '...';
+ const notification = ' [text truncated for brevity]';
+ const halfMaxLength = Math.floor((maxLength - ellipsis.length - notification.length) / 2);
+
+ if (text.length > maxLength) {
+ const startLastHalf = text.length - halfMaxLength;
+ return `${text.slice(0, halfMaxLength)}${ellipsis}${text.slice(startLastHalf)}${notification}`;
+ }
+
+ return text;
+}
+
+/**
+ * @param {TMessage[]} _messages
+ * @param {number} maxContextTokens
+ * @param {function({role: string, content: TMessageContent[]}): number} getTokenCountForMessage
+ *
+ * @returns {{
+ * dbMessages: TMessage[],
+ * editedIndices: number[]
+ * }}
+ */
+function truncateToolCallOutputs(_messages, maxContextTokens, getTokenCountForMessage) {
+ const THRESHOLD_PERCENTAGE = 0.5;
+ const targetTokenLimit = maxContextTokens * THRESHOLD_PERCENTAGE;
+
+ let currentTokenCount = 3;
+ const messages = [..._messages];
+ const processedMessages = [];
+ let currentIndex = messages.length;
+ const editedIndices = new Set();
+ while (messages.length > 0) {
+ currentIndex--;
+ const message = messages.pop();
+ currentTokenCount += message.tokenCount;
+ if (currentTokenCount < targetTokenLimit) {
+ processedMessages.push(message);
+ continue;
+ }
+
+ if (!message.content || !Array.isArray(message.content)) {
+ processedMessages.push(message);
+ continue;
+ }
+
+ const toolCallIndices = message.content
+ .map((item, index) => (item.type === 'tool_call' ? index : -1))
+ .filter((index) => index !== -1)
+ .reverse();
+
+ if (toolCallIndices.length === 0) {
+ processedMessages.push(message);
+ continue;
+ }
+
+ const newContent = [...message.content];
+
+ // Truncate all tool outputs since we're over threshold
+ for (const index of toolCallIndices) {
+ const toolCall = newContent[index].tool_call;
+ if (!toolCall || !toolCall.output) {
+ continue;
+ }
+
+ editedIndices.add(currentIndex);
+
+ newContent[index] = {
+ ...newContent[index],
+ tool_call: {
+ ...toolCall,
+ output: '[OUTPUT_OMITTED_FOR_BREVITY]',
+ },
+ };
+ }
+
+ const truncatedMessage = {
+ ...message,
+ content: newContent,
+ tokenCount: getTokenCountForMessage({ role: 'assistant', content: newContent }),
+ };
+
+ processedMessages.push(truncatedMessage);
+ }
+
+ return { dbMessages: processedMessages.reverse(), editedIndices: Array.from(editedIndices) };
+}
+
+module.exports = { truncateText, smartTruncateText, truncateToolCallOutputs };
diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js
new file mode 100644
index 0000000000..3cc082ab66
--- /dev/null
+++ b/api/app/clients/specs/BaseClient.test.js
@@ -0,0 +1,931 @@
+const { Constants } = require('librechat-data-provider');
+const { initializeFakeClient } = require('./FakeClient');
+
+jest.mock('~/db/connect');
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: jest.fn().mockResolvedValue({
+ // Default app config for tests
+ paths: { uploads: '/tmp' },
+ fileStrategy: 'local',
+ memory: { disabled: false },
+ }),
+}));
+jest.mock('~/models', () => ({
+ User: jest.fn(),
+ Key: jest.fn(),
+ Session: jest.fn(),
+ Balance: jest.fn(),
+ Transaction: jest.fn(),
+ getMessages: jest.fn().mockResolvedValue([]),
+ saveMessage: jest.fn(),
+ updateMessage: jest.fn(),
+ deleteMessagesSince: jest.fn(),
+ deleteMessages: jest.fn(),
+ getConvoTitle: jest.fn(),
+ getConvo: jest.fn(),
+ saveConvo: jest.fn(),
+ deleteConvos: jest.fn(),
+ getPreset: jest.fn(),
+ getPresets: jest.fn(),
+ savePreset: jest.fn(),
+ deletePresets: jest.fn(),
+ findFileById: jest.fn(),
+ createFile: jest.fn(),
+ updateFile: jest.fn(),
+ deleteFile: jest.fn(),
+ deleteFiles: jest.fn(),
+ getFiles: jest.fn(),
+ updateFileUsage: jest.fn(),
+}));
+
+const { getConvo, saveConvo } = require('~/models');
+
+jest.mock('@librechat/agents', () => {
+ const { Providers } = jest.requireActual('@librechat/agents');
+ return {
+ Providers,
+ ChatOpenAI: jest.fn().mockImplementation(() => {
+ return {};
+ }),
+ };
+});
+
+let parentMessageId;
+let conversationId;
+const fakeMessages = [];
+const userMessage = 'Hello, ChatGPT!';
+const apiKey = 'fake-api-key';
+
+const messageHistory = [
+ { role: 'user', isCreatedByUser: true, text: 'Hello', messageId: '1' },
+ { role: 'assistant', isCreatedByUser: false, text: 'Hi', messageId: '2', parentMessageId: '1' },
+ {
+ role: 'user',
+ isCreatedByUser: true,
+ text: "What's up",
+ messageId: '3',
+ parentMessageId: '2',
+ },
+];
+
+describe('BaseClient', () => {
+ let TestClient;
+ const options = {
+ // debug: true,
+ modelOptions: {
+ model: 'gpt-4o-mini',
+ temperature: 0,
+ },
+ };
+
+ beforeEach(() => {
+ TestClient = initializeFakeClient(apiKey, options, fakeMessages);
+ TestClient.summarizeMessages = jest.fn().mockResolvedValue({
+ summaryMessage: {
+ role: 'system',
+ content: 'Refined answer',
+ },
+ summaryTokenCount: 5,
+ });
+ });
+
+ test('returns the input messages without instructions when addInstructions() is called with empty instructions', () => {
+ const messages = [{ content: 'Hello' }, { content: 'How are you?' }, { content: 'Goodbye' }];
+ const instructions = '';
+ const result = TestClient.addInstructions(messages, instructions);
+ expect(result).toEqual(messages);
+ });
+
+ test('returns the input messages with instructions properly added when addInstructions() is called with non-empty instructions', () => {
+ const messages = [{ content: 'Hello' }, { content: 'How are you?' }, { content: 'Goodbye' }];
+ const instructions = { content: 'Please respond to the question.' };
+ const result = TestClient.addInstructions(messages, instructions);
+ const expected = [
+ { content: 'Please respond to the question.' },
+ { content: 'Hello' },
+ { content: 'How are you?' },
+ { content: 'Goodbye' },
+ ];
+ expect(result).toEqual(expected);
+ });
+
+ test('returns the input messages with instructions properly added when addInstructions() with legacy flag', () => {
+ const messages = [{ content: 'Hello' }, { content: 'How are you?' }, { content: 'Goodbye' }];
+ const instructions = { content: 'Please respond to the question.' };
+ const result = TestClient.addInstructions(messages, instructions, true);
+ const expected = [
+ { content: 'Hello' },
+ { content: 'How are you?' },
+ { content: 'Please respond to the question.' },
+ { content: 'Goodbye' },
+ ];
+ expect(result).toEqual(expected);
+ });
+
+ test('concats messages correctly in concatenateMessages()', () => {
+ const messages = [
+ { name: 'User', content: 'Hello' },
+ { name: 'Assistant', content: 'How can I help you?' },
+ { name: 'User', content: 'I have a question.' },
+ ];
+ const result = TestClient.concatenateMessages(messages);
+ const expected =
+ 'User:\nHello\n\nAssistant:\nHow can I help you?\n\nUser:\nI have a question.\n\n';
+ expect(result).toBe(expected);
+ });
+
+ test('refines messages correctly in summarizeMessages()', async () => {
+ const messagesToRefine = [
+ { role: 'user', content: 'Hello', tokenCount: 10 },
+ { role: 'assistant', content: 'How can I help you?', tokenCount: 20 },
+ ];
+ const remainingContextTokens = 100;
+ const expectedRefinedMessage = {
+ role: 'system',
+ content: 'Refined answer',
+ };
+
+ const result = await TestClient.summarizeMessages({ messagesToRefine, remainingContextTokens });
+ expect(result.summaryMessage).toEqual(expectedRefinedMessage);
+ });
+
+ test('gets messages within token limit (under limit) correctly in getMessagesWithinTokenLimit()', async () => {
+ TestClient.maxContextTokens = 100;
+ TestClient.shouldSummarize = true;
+
+ const messages = [
+ { role: 'user', content: 'Hello', tokenCount: 5 },
+ { role: 'assistant', content: 'How can I help you?', tokenCount: 19 },
+ { role: 'user', content: 'I have a question.', tokenCount: 18 },
+ ];
+ const expectedContext = [
+ { role: 'user', content: 'Hello', tokenCount: 5 }, // 'Hello'.length
+ { role: 'assistant', content: 'How can I help you?', tokenCount: 19 },
+ { role: 'user', content: 'I have a question.', tokenCount: 18 },
+ ];
+ // Subtract 3 tokens for Assistant Label priming after all messages have been counted.
+ const expectedRemainingContextTokens = 58 - 3; // (100 - 5 - 19 - 18) - 3
+ const expectedMessagesToRefine = [];
+
+ const lastExpectedMessage =
+ expectedMessagesToRefine?.[expectedMessagesToRefine.length - 1] ?? {};
+ const expectedIndex = messages.findIndex((msg) => msg.content === lastExpectedMessage?.content);
+
+ const result = await TestClient.getMessagesWithinTokenLimit({ messages });
+
+ expect(result.context).toEqual(expectedContext);
+ expect(result.messagesToRefine.length - 1).toEqual(expectedIndex);
+ expect(result.remainingContextTokens).toBe(expectedRemainingContextTokens);
+ expect(result.messagesToRefine).toEqual(expectedMessagesToRefine);
+ });
+
+ test('gets result over token limit correctly in getMessagesWithinTokenLimit()', async () => {
+ TestClient.maxContextTokens = 50; // Set a lower limit
+ TestClient.shouldSummarize = true;
+
+ const messages = [
+ { role: 'user', content: 'Hello', tokenCount: 30 },
+ { role: 'assistant', content: 'How can I help you?', tokenCount: 30 },
+ { role: 'user', content: 'I have a question.', tokenCount: 5 },
+ { role: 'user', content: 'I need a coffee, stat!', tokenCount: 19 },
+ { role: 'assistant', content: 'Sure, I can help with that.', tokenCount: 18 },
+ ];
+
+ // Subtract 3 tokens for Assistant Label priming after all messages have been counted.
+ const expectedRemainingContextTokens = 5; // (50 - 18 - 19 - 5) - 3
+ const expectedMessagesToRefine = [
+ { role: 'user', content: 'Hello', tokenCount: 30 },
+ { role: 'assistant', content: 'How can I help you?', tokenCount: 30 },
+ ];
+ const expectedContext = [
+ { role: 'user', content: 'I have a question.', tokenCount: 5 },
+ { role: 'user', content: 'I need a coffee, stat!', tokenCount: 19 },
+ { role: 'assistant', content: 'Sure, I can help with that.', tokenCount: 18 },
+ ];
+
+ const lastExpectedMessage =
+ expectedMessagesToRefine?.[expectedMessagesToRefine.length - 1] ?? {};
+ const expectedIndex = messages.findIndex((msg) => msg.content === lastExpectedMessage?.content);
+
+ const result = await TestClient.getMessagesWithinTokenLimit({ messages });
+
+ expect(result.context).toEqual(expectedContext);
+ expect(result.messagesToRefine.length - 1).toEqual(expectedIndex);
+ expect(result.remainingContextTokens).toBe(expectedRemainingContextTokens);
+ expect(result.messagesToRefine).toEqual(expectedMessagesToRefine);
+ });
+
+ describe('getMessagesForConversation', () => {
+ it('should return an empty array if the parentMessageId does not exist', () => {
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: unorderedMessages,
+ parentMessageId: '999',
+ });
+ expect(result).toEqual([]);
+ });
+
+ it('should handle messages with messageId property', () => {
+ const messagesWithMessageId = [
+ { messageId: '1', parentMessageId: null, text: 'Message 1' },
+ { messageId: '2', parentMessageId: '1', text: 'Message 2' },
+ ];
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: messagesWithMessageId,
+ parentMessageId: '2',
+ });
+ expect(result).toEqual([
+ { messageId: '1', parentMessageId: null, text: 'Message 1' },
+ { messageId: '2', parentMessageId: '1', text: 'Message 2' },
+ ]);
+ });
+
+ const messagesWithNullParent = [
+ { id: '1', parentMessageId: null, text: 'Message 1' },
+ { id: '2', parentMessageId: null, text: 'Message 2' },
+ ];
+
+ it('should handle messages with null parentMessageId that are not root', () => {
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: messagesWithNullParent,
+ parentMessageId: '2',
+ });
+ expect(result).toEqual([{ id: '2', parentMessageId: null, text: 'Message 2' }]);
+ });
+
+ const cyclicMessages = [
+ { id: '3', parentMessageId: '2', text: 'Message 3' },
+ { id: '1', parentMessageId: '3', text: 'Message 1' },
+ { id: '2', parentMessageId: '1', text: 'Message 2' },
+ ];
+
+ it('should handle cyclic references without going into an infinite loop', () => {
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: cyclicMessages,
+ parentMessageId: '3',
+ });
+ expect(result).toEqual([
+ { id: '1', parentMessageId: '3', text: 'Message 1' },
+ { id: '2', parentMessageId: '1', text: 'Message 2' },
+ { id: '3', parentMessageId: '2', text: 'Message 3' },
+ ]);
+ });
+
+ const unorderedMessages = [
+ { id: '3', parentMessageId: '2', text: 'Message 3' },
+ { id: '2', parentMessageId: '1', text: 'Message 2' },
+ { id: '1', parentMessageId: Constants.NO_PARENT, text: 'Message 1' },
+ ];
+
+ it('should return ordered messages based on parentMessageId', () => {
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: unorderedMessages,
+ parentMessageId: '3',
+ });
+ expect(result).toEqual([
+ { id: '1', parentMessageId: Constants.NO_PARENT, text: 'Message 1' },
+ { id: '2', parentMessageId: '1', text: 'Message 2' },
+ { id: '3', parentMessageId: '2', text: 'Message 3' },
+ ]);
+ });
+
+ const unorderedBranchedMessages = [
+ { id: '4', parentMessageId: '2', text: 'Message 4', summary: 'Summary for Message 4' },
+ { id: '10', parentMessageId: '7', text: 'Message 10' },
+ { id: '1', parentMessageId: null, text: 'Message 1' },
+ { id: '6', parentMessageId: '5', text: 'Message 7' },
+ { id: '7', parentMessageId: '5', text: 'Message 7' },
+ { id: '2', parentMessageId: '1', text: 'Message 2' },
+ { id: '8', parentMessageId: '6', text: 'Message 8' },
+ { id: '5', parentMessageId: '3', text: 'Message 5' },
+ { id: '3', parentMessageId: '1', text: 'Message 3' },
+ { id: '6', parentMessageId: '4', text: 'Message 6' },
+ { id: '8', parentMessageId: '7', text: 'Message 9' },
+ { id: '9', parentMessageId: '7', text: 'Message 9' },
+ { id: '11', parentMessageId: '2', text: 'Message 11', summary: 'Summary for Message 11' },
+ ];
+
+ it('should return ordered messages from a branched array based on parentMessageId', () => {
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: unorderedBranchedMessages,
+ parentMessageId: '10',
+ summary: true,
+ });
+ expect(result).toEqual([
+ { id: '1', parentMessageId: null, text: 'Message 1' },
+ { id: '3', parentMessageId: '1', text: 'Message 3' },
+ { id: '5', parentMessageId: '3', text: 'Message 5' },
+ { id: '7', parentMessageId: '5', text: 'Message 7' },
+ { id: '10', parentMessageId: '7', text: 'Message 10' },
+ ]);
+ });
+
+ it('should return an empty array if no messages are provided', () => {
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: [],
+ parentMessageId: '3',
+ });
+ expect(result).toEqual([]);
+ });
+
+ it('should map over the ordered messages if mapMethod is provided', () => {
+ const mapMethod = (msg) => msg.text;
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: unorderedMessages,
+ parentMessageId: '3',
+ mapMethod,
+ });
+ expect(result).toEqual(['Message 1', 'Message 2', 'Message 3']);
+ });
+
+ let unorderedMessagesWithSummary = [
+ { id: '4', parentMessageId: '3', text: 'Message 4' },
+ { id: '2', parentMessageId: '1', text: 'Message 2', summary: 'Summary for Message 2' },
+ { id: '3', parentMessageId: '2', text: 'Message 3', summary: 'Summary for Message 3' },
+ { id: '1', parentMessageId: null, text: 'Message 1' },
+ ];
+
+ it('should start with the message that has a summary property and continue until the specified parentMessageId', () => {
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: unorderedMessagesWithSummary,
+ parentMessageId: '4',
+ summary: true,
+ });
+ expect(result).toEqual([
+ {
+ id: '3',
+ parentMessageId: '2',
+ role: 'system',
+ text: 'Summary for Message 3',
+ summary: 'Summary for Message 3',
+ },
+ { id: '4', parentMessageId: '3', text: 'Message 4' },
+ ]);
+ });
+
+ it('should handle multiple summaries and return the branch from the latest to the parentMessageId', () => {
+ unorderedMessagesWithSummary = [
+ { id: '5', parentMessageId: '4', text: 'Message 5' },
+ { id: '2', parentMessageId: '1', text: 'Message 2', summary: 'Summary for Message 2' },
+ { id: '3', parentMessageId: '2', text: 'Message 3', summary: 'Summary for Message 3' },
+ { id: '4', parentMessageId: '3', text: 'Message 4', summary: 'Summary for Message 4' },
+ { id: '1', parentMessageId: null, text: 'Message 1' },
+ ];
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: unorderedMessagesWithSummary,
+ parentMessageId: '5',
+ summary: true,
+ });
+ expect(result).toEqual([
+ {
+ id: '4',
+ parentMessageId: '3',
+ role: 'system',
+ text: 'Summary for Message 4',
+ summary: 'Summary for Message 4',
+ },
+ { id: '5', parentMessageId: '4', text: 'Message 5' },
+ ]);
+ });
+
+ it('should handle summary at root edge case and continue until the parentMessageId', () => {
+ unorderedMessagesWithSummary = [
+ { id: '5', parentMessageId: '4', text: 'Message 5' },
+ { id: '1', parentMessageId: null, text: 'Message 1', summary: 'Summary for Message 1' },
+ { id: '4', parentMessageId: '3', text: 'Message 4', summary: 'Summary for Message 4' },
+ { id: '2', parentMessageId: '1', text: 'Message 2', summary: 'Summary for Message 2' },
+ { id: '3', parentMessageId: '2', text: 'Message 3', summary: 'Summary for Message 3' },
+ ];
+ const result = TestClient.constructor.getMessagesForConversation({
+ messages: unorderedMessagesWithSummary,
+ parentMessageId: '5',
+ summary: true,
+ });
+ expect(result).toEqual([
+ {
+ id: '4',
+ parentMessageId: '3',
+ role: 'system',
+ text: 'Summary for Message 4',
+ summary: 'Summary for Message 4',
+ },
+ { id: '5', parentMessageId: '4', text: 'Message 5' },
+ ]);
+ });
+ });
+
+ describe('sendMessage', () => {
+ test('sendMessage should return a response message', async () => {
+ const expectedResult = expect.objectContaining({
+ sender: TestClient.sender,
+ text: expect.any(String),
+ isCreatedByUser: false,
+ messageId: expect.any(String),
+ parentMessageId: expect.any(String),
+ conversationId: expect.any(String),
+ });
+
+ const response = await TestClient.sendMessage(userMessage);
+ parentMessageId = response.messageId;
+ conversationId = response.conversationId;
+ expect(response).toEqual(expectedResult);
+ });
+
+ test('should replace responseMessageId with new UUID when isRegenerate is true and messageId ends with underscore', async () => {
+ const mockCrypto = require('crypto');
+ const newUUID = 'new-uuid-1234';
+ jest.spyOn(mockCrypto, 'randomUUID').mockReturnValue(newUUID);
+
+ const opts = {
+ isRegenerate: true,
+ responseMessageId: 'existing-message-id_',
+ };
+
+ await TestClient.setMessageOptions(opts);
+
+ expect(TestClient.responseMessageId).toBe(newUUID);
+ expect(TestClient.responseMessageId).not.toBe('existing-message-id_');
+
+ mockCrypto.randomUUID.mockRestore();
+ });
+
+ test('should not replace responseMessageId when isRegenerate is false', async () => {
+ const opts = {
+ isRegenerate: false,
+ responseMessageId: 'existing-message-id_',
+ };
+
+ await TestClient.setMessageOptions(opts);
+
+ expect(TestClient.responseMessageId).toBe('existing-message-id_');
+ });
+
+ test('should not replace responseMessageId when it does not end with underscore', async () => {
+ const opts = {
+ isRegenerate: true,
+ responseMessageId: 'existing-message-id',
+ };
+
+ await TestClient.setMessageOptions(opts);
+
+ expect(TestClient.responseMessageId).toBe('existing-message-id');
+ });
+
+ test('sendMessage should work with provided conversationId and parentMessageId', async () => {
+ const userMessage = 'Second message in the conversation';
+ const opts = {
+ conversationId,
+ parentMessageId,
+ getReqData: jest.fn(),
+ onStart: jest.fn(),
+ };
+
+ const expectedResult = expect.objectContaining({
+ sender: TestClient.sender,
+ text: expect.any(String),
+ isCreatedByUser: false,
+ messageId: expect.any(String),
+ parentMessageId: expect.any(String),
+ conversationId: opts.conversationId,
+ });
+
+ const response = await TestClient.sendMessage(userMessage, opts);
+ parentMessageId = response.messageId;
+ expect(response.conversationId).toEqual(conversationId);
+ expect(response).toEqual(expectedResult);
+ expect(opts.getReqData).toHaveBeenCalled();
+ expect(opts.onStart).toHaveBeenCalled();
+ expect(TestClient.getBuildMessagesOptions).toHaveBeenCalled();
+ expect(TestClient.getSaveOptions).toHaveBeenCalled();
+ });
+
+ test('should return chat history', async () => {
+ TestClient = initializeFakeClient(apiKey, options, messageHistory);
+ const chatMessages = await TestClient.loadHistory(conversationId, '2');
+ expect(TestClient.currentMessages).toHaveLength(2);
+ expect(chatMessages[0].text).toEqual('Hello');
+
+ const chatMessages2 = await TestClient.loadHistory(conversationId, '3');
+ expect(TestClient.currentMessages).toHaveLength(3);
+ expect(chatMessages2[chatMessages2.length - 1].text).toEqual("What's up");
+ });
+
+ /* Most of the new sendMessage logic revolving around edited/continued AI messages
+ * can be summarized by the following test. The condition will load the entire history up to
+ * the message that is being edited, which will trigger the AI API to 'continue' the response.
+ * The 'userMessage' is only passed by convention and is not necessary for the generation.
+ */
+ it('should not push userMessage to currentMessages when isEdited is true and vice versa', async () => {
+ const overrideParentMessageId = 'user-message-id';
+ const responseMessageId = 'response-message-id';
+ const newHistory = messageHistory.slice();
+ newHistory.push({
+ role: 'assistant',
+ isCreatedByUser: false,
+ text: 'test message',
+ messageId: responseMessageId,
+ parentMessageId: '3',
+ });
+
+ TestClient = initializeFakeClient(apiKey, options, newHistory);
+ const sendMessageOptions = {
+ isEdited: true,
+ overrideParentMessageId,
+ parentMessageId: '3',
+ responseMessageId,
+ };
+
+ await TestClient.sendMessage('test message', sendMessageOptions);
+ const currentMessages = TestClient.currentMessages;
+ expect(currentMessages[currentMessages.length - 1].messageId).not.toEqual(
+ overrideParentMessageId,
+ );
+
+ // Test the opposite case
+ sendMessageOptions.isEdited = false;
+ await TestClient.sendMessage('test message', sendMessageOptions);
+ const currentMessages2 = TestClient.currentMessages;
+ expect(currentMessages2[currentMessages2.length - 1].messageId).toEqual(
+ overrideParentMessageId,
+ );
+ });
+
+ test('setOptions is called with the correct arguments only when replaceOptions is set to true', async () => {
+ TestClient.setOptions = jest.fn();
+ const opts = { conversationId: '123', parentMessageId: '456', replaceOptions: true };
+ await TestClient.sendMessage('Hello, world!', opts);
+ expect(TestClient.setOptions).toHaveBeenCalledWith(opts);
+ TestClient.setOptions.mockClear();
+ });
+
+ test('loadHistory is called with the correct arguments', async () => {
+ const opts = { conversationId: '123', parentMessageId: '456' };
+ await TestClient.sendMessage('Hello, world!', opts);
+ expect(TestClient.loadHistory).toHaveBeenCalledWith(
+ opts.conversationId,
+ opts.parentMessageId,
+ );
+ });
+
+ test('getReqData is called with the correct arguments', async () => {
+ const getReqData = jest.fn();
+ const opts = { getReqData };
+ const response = await TestClient.sendMessage('Hello, world!', opts);
+ expect(getReqData).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userMessage: expect.objectContaining({ text: 'Hello, world!' }),
+ conversationId: response.conversationId,
+ responseMessageId: response.messageId,
+ }),
+ );
+ });
+
+ test('onStart is called with the correct arguments', async () => {
+ const onStart = jest.fn();
+ const opts = { onStart };
+ await TestClient.sendMessage('Hello, world!', opts);
+
+ expect(onStart).toHaveBeenCalledWith(
+ expect.objectContaining({ text: 'Hello, world!' }),
+ expect.any(String),
+ /** `isNewConvo` */
+ true,
+ );
+ });
+
+ test('saveMessageToDatabase is called with the correct arguments', async () => {
+ const saveOptions = TestClient.getSaveOptions();
+ const user = {};
+ const opts = { user };
+ const saveSpy = jest.spyOn(TestClient, 'saveMessageToDatabase');
+ await TestClient.sendMessage('Hello, world!', opts);
+ expect(saveSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sender: expect.any(String),
+ text: expect.any(String),
+ isCreatedByUser: expect.any(Boolean),
+ messageId: expect.any(String),
+ parentMessageId: expect.any(String),
+ conversationId: expect.any(String),
+ }),
+ saveOptions,
+ user,
+ );
+ });
+
+ test('should handle existing conversation when getConvo retrieves one', async () => {
+ const existingConvo = {
+ conversationId: 'existing-convo-id',
+ endpoint: 'openai',
+ endpointType: 'openai',
+ model: 'gpt-3.5-turbo',
+ messages: [
+ { role: 'user', content: 'Existing message 1' },
+ { role: 'assistant', content: 'Existing response 1' },
+ ],
+ temperature: 1,
+ };
+
+ const { temperature: _temp, ...newConvo } = existingConvo;
+
+ const user = {
+ id: 'user-id',
+ };
+
+ getConvo.mockResolvedValue(existingConvo);
+ saveConvo.mockResolvedValue(newConvo);
+
+ TestClient = initializeFakeClient(
+ apiKey,
+ {
+ ...options,
+ req: {
+ user,
+ },
+ },
+ [],
+ );
+
+ const saveSpy = jest.spyOn(TestClient, 'saveMessageToDatabase');
+
+ const newMessage = 'New message in existing conversation';
+ const response = await TestClient.sendMessage(newMessage, {
+ user,
+ conversationId: existingConvo.conversationId,
+ });
+
+ expect(getConvo).toHaveBeenCalledWith(user.id, existingConvo.conversationId);
+ expect(TestClient.conversationId).toBe(existingConvo.conversationId);
+ expect(response.conversationId).toBe(existingConvo.conversationId);
+ expect(TestClient.fetchedConvo).toBe(true);
+
+ expect(saveSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ conversationId: existingConvo.conversationId,
+ text: newMessage,
+ }),
+ expect.any(Object),
+ expect.any(Object),
+ );
+
+ expect(saveConvo).toHaveBeenCalledTimes(2);
+ expect(saveConvo).toHaveBeenCalledWith(
+ expect.any(Object),
+ expect.objectContaining({
+ conversationId: existingConvo.conversationId,
+ }),
+ expect.objectContaining({
+ context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveConvo',
+ unsetFields: {
+ temperature: 1,
+ },
+ }),
+ );
+
+ await TestClient.sendMessage('Another message', {
+ conversationId: existingConvo.conversationId,
+ });
+ expect(getConvo).toHaveBeenCalledTimes(1);
+ });
+
+ test('should correctly handle existing conversation and unset fields appropriately', async () => {
+ const existingConvo = {
+ conversationId: 'existing-convo-id',
+ endpoint: 'openai',
+ endpointType: 'openai',
+ model: 'gpt-3.5-turbo',
+ messages: [
+ { role: 'user', content: 'Existing message 1' },
+ { role: 'assistant', content: 'Existing response 1' },
+ ],
+ title: 'Existing Conversation',
+ someExistingField: 'existingValue',
+ anotherExistingField: 'anotherValue',
+ temperature: 0.7,
+ modelLabel: 'GPT-3.5',
+ };
+
+ getConvo.mockResolvedValue(existingConvo);
+ saveConvo.mockResolvedValue(existingConvo);
+
+ TestClient = initializeFakeClient(
+ apiKey,
+ {
+ ...options,
+ modelOptions: {
+ model: 'gpt-4',
+ temperature: 0.5,
+ },
+ },
+ [],
+ );
+
+ const newMessage = 'New message in existing conversation';
+ await TestClient.sendMessage(newMessage, {
+ conversationId: existingConvo.conversationId,
+ });
+
+ expect(saveConvo).toHaveBeenCalledTimes(2);
+
+ const saveConvoCall = saveConvo.mock.calls[0];
+ const [, savedFields, saveOptions] = saveConvoCall;
+
+ // Instead of checking all excludedKeys, we'll just check specific fields
+ // that we know should be excluded
+ expect(savedFields).not.toHaveProperty('messages');
+ expect(savedFields).not.toHaveProperty('title');
+
+ // Only check that someExistingField is in unsetFields
+ expect(saveOptions.unsetFields).toHaveProperty('someExistingField', 1);
+
+ // Mock saveConvo to return the expected fields
+ saveConvo.mockImplementation((req, fields) => {
+ return Promise.resolve({
+ ...fields,
+ endpoint: 'openai',
+ endpointType: 'openai',
+ model: 'gpt-4',
+ temperature: 0.5,
+ });
+ });
+
+ // Only check the conversationId since that's the only field we can be sure about
+ expect(savedFields).toHaveProperty('conversationId', 'existing-convo-id');
+
+ expect(TestClient.fetchedConvo).toBe(true);
+
+ await TestClient.sendMessage('Another message', {
+ conversationId: existingConvo.conversationId,
+ });
+
+ expect(getConvo).toHaveBeenCalledTimes(1);
+
+ const secondSaveConvoCall = saveConvo.mock.calls[1];
+ expect(secondSaveConvoCall[2]).toHaveProperty('unsetFields', {});
+ });
+
+ test('sendCompletion is called with the correct arguments', async () => {
+ const payload = {}; // Mock payload
+ TestClient.buildMessages.mockReturnValue({ prompt: payload, tokenCountMap: null });
+ const opts = {};
+ await TestClient.sendMessage('Hello, world!', opts);
+ expect(TestClient.sendCompletion).toHaveBeenCalledWith(payload, opts);
+ });
+
+ test('getTokenCount for response is called with the correct arguments', async () => {
+ const tokenCountMap = {}; // Mock tokenCountMap
+ TestClient.buildMessages.mockReturnValue({ prompt: [], tokenCountMap });
+ TestClient.getTokenCountForResponse = jest.fn();
+ const response = await TestClient.sendMessage('Hello, world!', {});
+ expect(TestClient.getTokenCountForResponse).toHaveBeenCalledWith(response);
+ });
+
+ test('returns an object with the correct shape', async () => {
+ const response = await TestClient.sendMessage('Hello, world!', {});
+ expect(response).toEqual(
+ expect.objectContaining({
+ sender: expect.any(String),
+ text: expect.any(String),
+ isCreatedByUser: expect.any(Boolean),
+ messageId: expect.any(String),
+ parentMessageId: expect.any(String),
+ conversationId: expect.any(String),
+ }),
+ );
+ });
+
+ test('userMessagePromise is awaited before saving response message', async () => {
+ // Mock the saveMessageToDatabase method
+ TestClient.saveMessageToDatabase = jest.fn().mockImplementation(() => {
+ return new Promise((resolve) => setTimeout(resolve, 100)); // Simulate a delay
+ });
+
+ // Send a message
+ const messagePromise = TestClient.sendMessage('Hello, world!');
+
+ // Wait a short time to ensure the user message save has started
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ // Check that saveMessageToDatabase has been called once (for the user message)
+ expect(TestClient.saveMessageToDatabase).toHaveBeenCalledTimes(1);
+
+ // Wait for the message to be fully processed
+ await messagePromise;
+
+ // Check that saveMessageToDatabase has been called twice (once for user message, once for response)
+ expect(TestClient.saveMessageToDatabase).toHaveBeenCalledTimes(2);
+
+ // Check the order of calls
+ const calls = TestClient.saveMessageToDatabase.mock.calls;
+ expect(calls[0][0].isCreatedByUser).toBe(true); // First call should be for user message
+ expect(calls[1][0].isCreatedByUser).toBe(false); // Second call should be for response message
+ });
+ });
+
+ describe('getMessagesWithinTokenLimit with instructions', () => {
+ test('should always include instructions when present', async () => {
+ TestClient.maxContextTokens = 50;
+ const instructions = {
+ role: 'system',
+ content: 'System instructions',
+ tokenCount: 20,
+ };
+
+ const messages = [
+ instructions,
+ { role: 'user', content: 'Hello', tokenCount: 10 },
+ { role: 'assistant', content: 'Hi there', tokenCount: 15 },
+ ];
+
+ const result = await TestClient.getMessagesWithinTokenLimit({
+ messages,
+ instructions,
+ });
+
+ expect(result.context[0]).toBe(instructions);
+ expect(result.remainingContextTokens).toBe(2);
+ });
+
+ test('should handle case when messages exceed limit but instructions must be preserved', async () => {
+ TestClient.maxContextTokens = 30;
+ const instructions = {
+ role: 'system',
+ content: 'System instructions',
+ tokenCount: 20,
+ };
+
+ const messages = [
+ instructions,
+ { role: 'user', content: 'Hello', tokenCount: 10 },
+ { role: 'assistant', content: 'Hi there', tokenCount: 15 },
+ ];
+
+ const result = await TestClient.getMessagesWithinTokenLimit({
+ messages,
+ instructions,
+ });
+
+ // Should only include instructions and the last message that fits
+ expect(result.context).toHaveLength(1);
+ expect(result.context[0].content).toBe(instructions.content);
+ expect(result.messagesToRefine).toHaveLength(2);
+ expect(result.remainingContextTokens).toBe(7); // 30 - 20 - 3 (assistant label)
+ });
+
+ test('should work correctly without instructions (1/2)', async () => {
+ TestClient.maxContextTokens = 50;
+ const messages = [
+ { role: 'user', content: 'Hello', tokenCount: 10 },
+ { role: 'assistant', content: 'Hi there', tokenCount: 15 },
+ ];
+
+ const result = await TestClient.getMessagesWithinTokenLimit({
+ messages,
+ });
+
+ expect(result.context).toHaveLength(2);
+ expect(result.remainingContextTokens).toBe(22); // 50 - 10 - 15 - 3(assistant label)
+ expect(result.messagesToRefine).toHaveLength(0);
+ });
+
+ test('should work correctly without instructions (2/2)', async () => {
+ TestClient.maxContextTokens = 30;
+ const messages = [
+ { role: 'user', content: 'Hello', tokenCount: 10 },
+ { role: 'assistant', content: 'Hi there', tokenCount: 20 },
+ ];
+
+ const result = await TestClient.getMessagesWithinTokenLimit({
+ messages,
+ });
+
+ expect(result.context).toHaveLength(1);
+ expect(result.remainingContextTokens).toBe(7);
+ expect(result.messagesToRefine).toHaveLength(1);
+ });
+
+ test('should handle case when only instructions fit within limit', async () => {
+ TestClient.maxContextTokens = 25;
+ const instructions = {
+ role: 'system',
+ content: 'System instructions',
+ tokenCount: 20,
+ };
+
+ const messages = [
+ instructions,
+ { role: 'user', content: 'Hello', tokenCount: 10 },
+ { role: 'assistant', content: 'Hi there', tokenCount: 15 },
+ ];
+
+ const result = await TestClient.getMessagesWithinTokenLimit({
+ messages,
+ instructions,
+ });
+
+ expect(result.context).toHaveLength(1);
+ expect(result.context[0]).toBe(instructions);
+ expect(result.messagesToRefine).toHaveLength(2);
+ expect(result.remainingContextTokens).toBe(2); // 25 - 20 - 3(assistant label)
+ });
+ });
+});
diff --git a/api/app/clients/specs/FakeClient.js b/api/app/clients/specs/FakeClient.js
new file mode 100644
index 0000000000..d1d07a967d
--- /dev/null
+++ b/api/app/clients/specs/FakeClient.js
@@ -0,0 +1,126 @@
+const { getModelMaxTokens } = require('@librechat/api');
+const BaseClient = require('../BaseClient');
+
+class FakeClient extends BaseClient {
+ constructor(apiKey, options = {}) {
+ super(apiKey, options);
+ this.sender = 'AI Assistant';
+ this.setOptions(options);
+ }
+ setOptions(options) {
+ if (this.options && !this.options.replaceOptions) {
+ this.options.modelOptions = {
+ ...this.options.modelOptions,
+ ...options.modelOptions,
+ };
+ delete options.modelOptions;
+ this.options = {
+ ...this.options,
+ ...options,
+ };
+ } else {
+ this.options = options;
+ }
+
+ if (this.options.openaiApiKey) {
+ this.apiKey = this.options.openaiApiKey;
+ }
+
+ const modelOptions = this.options.modelOptions || {};
+ if (!this.modelOptions) {
+ this.modelOptions = {
+ ...modelOptions,
+ model: modelOptions.model || 'gpt-3.5-turbo',
+ temperature:
+ typeof modelOptions.temperature === 'undefined' ? 0.8 : modelOptions.temperature,
+ top_p: typeof modelOptions.top_p === 'undefined' ? 1 : modelOptions.top_p,
+ presence_penalty:
+ typeof modelOptions.presence_penalty === 'undefined' ? 1 : modelOptions.presence_penalty,
+ stop: modelOptions.stop,
+ };
+ }
+
+ this.maxContextTokens =
+ this.options.maxContextTokens ?? getModelMaxTokens(this.modelOptions.model) ?? 4097;
+ }
+ buildMessages() {}
+ getTokenCount(str) {
+ return str.length;
+ }
+ getTokenCountForMessage(message) {
+ return message?.content?.length || message.length;
+ }
+}
+
+const initializeFakeClient = (apiKey, options, fakeMessages) => {
+ let TestClient = new FakeClient(apiKey);
+ TestClient.options = options;
+ TestClient.abortController = { abort: jest.fn() };
+ TestClient.loadHistory = jest
+ .fn()
+ .mockImplementation((conversationId, parentMessageId = null) => {
+ if (!conversationId) {
+ TestClient.currentMessages = [];
+ return Promise.resolve([]);
+ }
+
+ const orderedMessages = TestClient.constructor.getMessagesForConversation({
+ messages: fakeMessages,
+ parentMessageId,
+ });
+
+ TestClient.currentMessages = orderedMessages;
+ return Promise.resolve(orderedMessages);
+ });
+
+ TestClient.getSaveOptions = jest.fn().mockImplementation(() => {
+ return {};
+ });
+
+ TestClient.getBuildMessagesOptions = jest.fn().mockImplementation(() => {
+ return {};
+ });
+
+ TestClient.sendCompletion = jest.fn(async () => {
+ return {
+ completion: 'Mock response text',
+ metadata: undefined,
+ };
+ });
+
+ TestClient.getCompletion = jest.fn().mockImplementation(async (..._args) => {
+ return {
+ choices: [
+ {
+ message: {
+ content: 'Mock response text',
+ },
+ },
+ ],
+ };
+ });
+
+ TestClient.buildMessages = jest.fn(async (messages, parentMessageId) => {
+ const orderedMessages = TestClient.constructor.getMessagesForConversation({
+ messages,
+ parentMessageId,
+ });
+ const formattedMessages = orderedMessages.map((message) => {
+ let { role: _role, sender, text } = message;
+ const role = _role ?? sender;
+ const content = text ?? '';
+ return {
+ role: role?.toLowerCase() === 'user' ? 'user' : 'assistant',
+ content,
+ };
+ });
+ return {
+ prompt: formattedMessages,
+ tokenCountMap: null, // Simplified for the mock
+ };
+ });
+
+ return TestClient;
+};
+
+module.exports = { FakeClient, initializeFakeClient };
diff --git a/api/app/clients/tools/index.js b/api/app/clients/tools/index.js
new file mode 100644
index 0000000000..90d1545a5a
--- /dev/null
+++ b/api/app/clients/tools/index.js
@@ -0,0 +1,30 @@
+const manifest = require('./manifest');
+
+// Structured Tools
+const DALLE3 = require('./structured/DALLE3');
+const FluxAPI = require('./structured/FluxAPI');
+const OpenWeather = require('./structured/OpenWeather');
+const StructuredWolfram = require('./structured/Wolfram');
+const createYouTubeTools = require('./structured/YouTube');
+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');
+
+module.exports = {
+ ...manifest,
+ // Structured Tools
+ DALLE3,
+ FluxAPI,
+ OpenWeather,
+ StructuredSD,
+ StructuredACS,
+ GoogleSearchAPI,
+ TraversaalSearch,
+ StructuredWolfram,
+ createYouTubeTools,
+ TavilySearchResults,
+ createOpenAIImageTools,
+};
diff --git a/api/app/clients/tools/manifest.js b/api/app/clients/tools/manifest.js
new file mode 100644
index 0000000000..302d9c3dfe
--- /dev/null
+++ b/api/app/clients/tools/manifest.js
@@ -0,0 +1,20 @@
+const availableTools = require('./manifest.json');
+
+/** @type {Record} */
+const manifestToolMap = {};
+
+/** @type {Array} */
+const toolkits = [];
+
+availableTools.forEach((tool) => {
+ manifestToolMap[tool.pluginKey] = tool;
+ if (tool.toolkit === true) {
+ toolkits.push(tool);
+ }
+});
+
+module.exports = {
+ toolkits,
+ availableTools,
+ manifestToolMap,
+};
diff --git a/api/app/clients/tools/manifest.json b/api/app/clients/tools/manifest.json
new file mode 100644
index 0000000000..c12b962fee
--- /dev/null
+++ b/api/app/clients/tools/manifest.json
@@ -0,0 +1,183 @@
+[
+ {
+ "name": "Traversaal",
+ "pluginKey": "traversaal_search",
+ "description": "Traversaal is a robust search API tailored for LLM Agents. Get an API key here: https://api.traversaal.ai",
+ "icon": "https://traversaal.ai/favicon.ico",
+ "authConfig": [
+ {
+ "authField": "TRAVERSAAL_API_KEY",
+ "label": "Traversaal API Key",
+ "description": "Get your API key here: https://api.traversaal.ai "
+ }
+ ]
+ },
+ {
+ "name": "Google",
+ "pluginKey": "google",
+ "description": "Use Google Search to find information about the weather, news, sports, and more.",
+ "icon": "https://i.imgur.com/SMmVkNB.png",
+ "authConfig": [
+ {
+ "authField": "GOOGLE_CSE_ID",
+ "label": "Google CSE ID",
+ "description": "This is your Google Custom Search Engine ID. For instructions on how to obtain this, see Our Docs ."
+ },
+ {
+ "authField": "GOOGLE_SEARCH_API_KEY",
+ "label": "Google API Key",
+ "description": "This is your Google Custom Search API Key. For instructions on how to obtain this, see Our Docs ."
+ }
+ ]
+ },
+ {
+ "name": "YouTube",
+ "pluginKey": "youtube",
+ "toolkit": true,
+ "description": "Get YouTube video information, retrieve comments, analyze transcripts and search for videos.",
+ "icon": "https://www.youtube.com/s/desktop/7449ebf7/img/favicon_144x144.png",
+ "authConfig": [
+ {
+ "authField": "YOUTUBE_API_KEY",
+ "label": "YouTube API Key",
+ "description": "Your YouTube Data API v3 key."
+ }
+ ]
+ },
+ {
+ "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",
+ "description": "Access computation, math, curated knowledge & real-time data through Wolfram|Alpha and Wolfram Language.",
+ "icon": "https://www.wolframcdn.com/images/icons/Wolfram.png",
+ "authConfig": [
+ {
+ "authField": "WOLFRAM_APP_ID",
+ "label": "Wolfram App ID",
+ "description": "An AppID must be supplied in all calls to the Wolfram|Alpha API. You can get one by registering at Wolfram|Alpha and going to the Developer Portal ."
+ }
+ ]
+ },
+ {
+ "name": "Browser",
+ "pluginKey": "web-browser",
+ "description": "Scrape and summarize webpage data",
+ "icon": "assets/web-browser.svg",
+ "authConfig": [
+ {
+ "authField": "OPENAI_API_KEY",
+ "label": "OpenAI API Key",
+ "description": "Browser makes use of OpenAI embeddings"
+ }
+ ]
+ },
+ {
+ "name": "DALL-E-3",
+ "pluginKey": "dalle",
+ "description": "[DALL-E-3] Create realistic images and art from a description in natural language",
+ "icon": "https://i.imgur.com/u2TzXzH.png",
+ "authConfig": [
+ {
+ "authField": "DALLE3_API_KEY||DALLE_API_KEY",
+ "label": "OpenAI API Key",
+ "description": "You can use DALL-E with your API Key from OpenAI."
+ }
+ ]
+ },
+ {
+ "name": "Tavily Search",
+ "pluginKey": "tavily_search_results_json",
+ "description": "Tavily Search is a robust search API tailored for LLM Agents. It seamlessly integrates with diverse data sources to ensure a superior, relevant search experience.",
+ "icon": "https://tavily.com/favicon.ico",
+ "authConfig": [
+ {
+ "authField": "TAVILY_API_KEY",
+ "label": "Tavily API Key",
+ "description": "Get your API key here: https://app.tavily.com/"
+ }
+ ]
+ },
+ {
+ "name": "Calculator",
+ "pluginKey": "calculator",
+ "description": "Perform simple and complex mathematical calculations.",
+ "icon": "https://i.imgur.com/RHsSG5h.png",
+ "authConfig": []
+ },
+ {
+ "name": "Stable Diffusion",
+ "pluginKey": "stable-diffusion",
+ "description": "Generate photo-realistic images given any text input.",
+ "icon": "https://i.imgur.com/Yr466dp.png",
+ "authConfig": [
+ {
+ "authField": "SD_WEBUI_URL",
+ "label": "Your Stable Diffusion WebUI API URL",
+ "description": "You need to provide the URL of your Stable Diffusion WebUI API. For instructions on how to obtain this, see Our Docs ."
+ }
+ ]
+ },
+ {
+ "name": "Azure AI Search",
+ "pluginKey": "azure-ai-search",
+ "description": "Use Azure AI Search to find information",
+ "icon": "https://i.imgur.com/E7crPze.png",
+ "authConfig": [
+ {
+ "authField": "AZURE_AI_SEARCH_SERVICE_ENDPOINT",
+ "label": "Azure AI Search Endpoint",
+ "description": "You need to provide your Endpoint for Azure AI Search."
+ },
+ {
+ "authField": "AZURE_AI_SEARCH_INDEX_NAME",
+ "label": "Azure AI Search Index Name",
+ "description": "You need to provide your Index Name for Azure AI Search."
+ },
+ {
+ "authField": "AZURE_AI_SEARCH_API_KEY",
+ "label": "Azure AI Search API Key",
+ "description": "You need to provide your API Key for Azure AI Search."
+ }
+ ]
+ },
+ {
+ "name": "OpenWeather",
+ "pluginKey": "open_weather",
+ "description": "Get weather forecasts and historical data from the OpenWeather API",
+ "icon": "assets/openweather.png",
+ "authConfig": [
+ {
+ "authField": "OPENWEATHER_API_KEY",
+ "label": "OpenWeather API Key",
+ "description": "Sign up at OpenWeather , then get your key at API keys ."
+ }
+ ]
+ },
+ {
+ "name": "Flux",
+ "pluginKey": "flux",
+ "description": "Generate images using text with the Flux API.",
+ "icon": "https://blackforestlabs.ai/wp-content/uploads/2024/07/bfl_logo_retraced_blk.png",
+ "isAuthRequired": "true",
+ "authConfig": [
+ {
+ "authField": "FLUX_API_KEY",
+ "label": "Your Flux API Key",
+ "description": "Provide your Flux API key from your user profile."
+ }
+ ]
+ }
+]
diff --git a/api/app/clients/tools/structured/AzureAISearch.js b/api/app/clients/tools/structured/AzureAISearch.js
new file mode 100644
index 0000000000..55af3cdff5
--- /dev/null
+++ b/api/app/clients/tools/structured/AzureAISearch.js
@@ -0,0 +1,104 @@
+const { z } = require('zod');
+const { Tool } = require('@langchain/core/tools');
+const { logger } = require('@librechat/data-schemas');
+const { SearchClient, AzureKeyCredential } = require('@azure/search-documents');
+
+class AzureAISearch extends Tool {
+ // Constants for default values
+ static DEFAULT_API_VERSION = '2023-11-01';
+ static DEFAULT_QUERY_TYPE = 'simple';
+ static DEFAULT_TOP = 5;
+
+ // Helper function for initializing properties
+ _initializeField(field, envVar, defaultValue) {
+ return field || process.env[envVar] || defaultValue;
+ }
+
+ constructor(fields = {}) {
+ super();
+ this.name = 'azure-ai-search';
+ this.description =
+ "Use the 'azure-ai-search' tool to retrieve search results relevant to your input";
+ /* Used to initialize the Tool without necessary variables. */
+ this.override = fields.override ?? false;
+
+ // Define schema
+ this.schema = z.object({
+ query: z.string().describe('Search word or phrase to Azure AI Search'),
+ });
+
+ // Initialize properties using helper function
+ this.serviceEndpoint = this._initializeField(
+ fields.AZURE_AI_SEARCH_SERVICE_ENDPOINT,
+ 'AZURE_AI_SEARCH_SERVICE_ENDPOINT',
+ );
+ this.indexName = this._initializeField(
+ fields.AZURE_AI_SEARCH_INDEX_NAME,
+ 'AZURE_AI_SEARCH_INDEX_NAME',
+ );
+ this.apiKey = this._initializeField(fields.AZURE_AI_SEARCH_API_KEY, 'AZURE_AI_SEARCH_API_KEY');
+ this.apiVersion = this._initializeField(
+ fields.AZURE_AI_SEARCH_API_VERSION,
+ 'AZURE_AI_SEARCH_API_VERSION',
+ AzureAISearch.DEFAULT_API_VERSION,
+ );
+ this.queryType = this._initializeField(
+ fields.AZURE_AI_SEARCH_SEARCH_OPTION_QUERY_TYPE,
+ 'AZURE_AI_SEARCH_SEARCH_OPTION_QUERY_TYPE',
+ AzureAISearch.DEFAULT_QUERY_TYPE,
+ );
+ this.top = this._initializeField(
+ fields.AZURE_AI_SEARCH_SEARCH_OPTION_TOP,
+ 'AZURE_AI_SEARCH_SEARCH_OPTION_TOP',
+ AzureAISearch.DEFAULT_TOP,
+ );
+ this.select = this._initializeField(
+ fields.AZURE_AI_SEARCH_SEARCH_OPTION_SELECT,
+ 'AZURE_AI_SEARCH_SEARCH_OPTION_SELECT',
+ );
+
+ // Check for required fields
+ if (!this.override && (!this.serviceEndpoint || !this.indexName || !this.apiKey)) {
+ throw new Error(
+ 'Missing AZURE_AI_SEARCH_SERVICE_ENDPOINT, AZURE_AI_SEARCH_INDEX_NAME, or AZURE_AI_SEARCH_API_KEY environment variable.',
+ );
+ }
+
+ if (this.override) {
+ return;
+ }
+
+ // Create SearchClient
+ this.client = new SearchClient(
+ this.serviceEndpoint,
+ this.indexName,
+ new AzureKeyCredential(this.apiKey),
+ { apiVersion: this.apiVersion },
+ );
+ }
+
+ // Improved error handling and logging
+ async _call(data) {
+ const { query } = data;
+ try {
+ const searchOption = {
+ queryType: this.queryType,
+ top: typeof this.top === 'string' ? Number(this.top) : this.top,
+ };
+ if (this.select) {
+ searchOption.select = this.select.split(',');
+ }
+ const searchResults = await this.client.search(query, searchOption);
+ const resultDocuments = [];
+ for await (const result of searchResults.results) {
+ resultDocuments.push(result.document);
+ }
+ return JSON.stringify(resultDocuments);
+ } catch (error) {
+ logger.error('Azure AI Search request failed', error);
+ return 'There was an error with Azure AI Search.';
+ }
+ }
+}
+
+module.exports = AzureAISearch;
diff --git a/api/app/clients/tools/structured/DALLE3.js b/api/app/clients/tools/structured/DALLE3.js
new file mode 100644
index 0000000000..c44b56f83d
--- /dev/null
+++ b/api/app/clients/tools/structured/DALLE3.js
@@ -0,0 +1,230 @@
+const { z } = require('zod');
+const path = require('path');
+const OpenAI = require('openai');
+const { v4: uuidv4 } = require('uuid');
+const { ProxyAgent, fetch } = require('undici');
+const { Tool } = require('@langchain/core/tools');
+const { logger } = require('@librechat/data-schemas');
+const { getImageBasename, extractBaseURL } = require('@librechat/api');
+const { FileContext, ContentTypes } = require('librechat-data-provider');
+
+const displayMessage =
+ "DALL-E 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.";
+class DALLE3 extends Tool {
+ constructor(fields = {}) {
+ super();
+ /** @type {boolean} Used to initialize the Tool without necessary variables. */
+ this.override = fields.override ?? false;
+ /** @type {boolean} Necessary for output to contain all image metadata. */
+ this.returnMetadata = fields.returnMetadata ?? false;
+
+ this.userId = fields.userId;
+ this.fileStrategy = fields.fileStrategy;
+ /** @type {boolean} */
+ this.isAgent = fields.isAgent;
+ if (fields.processFileURL) {
+ /** @type {processFileURL} Necessary for output to contain all image metadata. */
+ this.processFileURL = fields.processFileURL.bind(this);
+ }
+
+ let apiKey = fields.DALLE3_API_KEY ?? fields.DALLE_API_KEY ?? this.getApiKey();
+ const config = { apiKey };
+ if (process.env.DALLE_REVERSE_PROXY) {
+ config.baseURL = extractBaseURL(process.env.DALLE_REVERSE_PROXY);
+ }
+
+ if (process.env.DALLE3_AZURE_API_VERSION && process.env.DALLE3_BASEURL) {
+ config.baseURL = process.env.DALLE3_BASEURL;
+ config.defaultQuery = { 'api-version': process.env.DALLE3_AZURE_API_VERSION };
+ config.defaultHeaders = {
+ 'api-key': process.env.DALLE3_API_KEY,
+ 'Content-Type': 'application/json',
+ };
+ config.apiKey = process.env.DALLE3_API_KEY;
+ }
+
+ if (process.env.PROXY) {
+ const proxyAgent = new ProxyAgent(process.env.PROXY);
+ config.fetchOptions = {
+ dispatcher: proxyAgent,
+ };
+ }
+
+ /** @type {OpenAI} */
+ this.openai = new OpenAI(config);
+ this.name = 'dalle';
+ this.description = `Use DALLE to create images from text descriptions.
+ - It requires prompts to be in English, detailed, and to specify image type and human features for diversity.
+ - Create only one image, without repeating or listing descriptions outside the "prompts" field.
+ - Maintains the original intent of the description, with parameters for image style, quality, and size to tailor the output.`;
+ this.description_for_model =
+ process.env.DALLE3_SYSTEM_PROMPT ??
+ `// Whenever a description of an image is given, generate prompts (following these rules), and use dalle to create the image. If the user does not ask for a specific number of images, default to creating 2 prompts to send to dalle that are written to be as diverse as possible. All prompts sent to dalle must abide by the following policies:
+ // 1. Prompts must be in English. Translate to English if needed.
+ // 2. One image per function call. Create only 1 image per request unless explicitly told to generate more than 1 image.
+ // 3. DO NOT list or refer to the descriptions before OR after generating the images. They should ONLY ever be written out ONCE, in the \`"prompts"\` field of the request. You do not need to ask for permission to generate, just do it!
+ // 4. Always mention the image type (photo, oil painting, watercolor painting, illustration, cartoon, drawing, vector, render, etc.) at the beginning of the caption. Unless the captions suggests otherwise, make one of the images a photo.
+ // 5. Diversify depictions of ALL images with people to always include always DESCENT and GENDER for EACH person using direct terms. Adjust only human descriptions.
+ // - EXPLICITLY specify these attributes, not abstractly reference them. The attributes should be specified in a minimal way and should directly describe their physical form.
+ // - Your choices should be grounded in reality. For example, all of a given OCCUPATION should not be the same gender or race. Additionally, focus on creating diverse, inclusive, and exploratory scenes via the properties you choose during rewrites. Make choices that may be insightful or unique sometimes.
+ // - Use "various" or "diverse" ONLY IF the description refers to groups of more than 3 people. Do not change the number of people requested in the original description.
+ // - Don't alter memes, fictional character origins, or unseen people. Maintain the original prompt's intent and prioritize quality.
+ // The prompt must intricately describe every part of the image in concrete, objective detail. THINK about what the end goal of the description is, and extrapolate that to what would make satisfying images.
+ // All descriptions sent to dalle should be a paragraph of text that is extremely descriptive and detailed. Each should be more than 3 sentences long.
+ // - The "vivid" style is HIGHLY preferred, but "natural" is also supported.`;
+ this.schema = z.object({
+ prompt: z
+ .string()
+ .max(4000)
+ .describe(
+ 'A text description of the desired image, following the rules, up to 4000 characters.',
+ ),
+ style: z
+ .enum(['vivid', 'natural'])
+ .describe(
+ 'Must be one of `vivid` or `natural`. `vivid` generates hyper-real and dramatic images, `natural` produces more natural, less hyper-real looking images',
+ ),
+ quality: z
+ .enum(['hd', 'standard'])
+ .describe('The quality of the generated image. Only `hd` and `standard` are supported.'),
+ size: z
+ .enum(['1024x1024', '1792x1024', '1024x1792'])
+ .describe(
+ 'The size of the requested image. Use 1024x1024 (square) as the default, 1792x1024 if the user requests a wide image, and 1024x1792 for full-body portraits. Always include this parameter in the request.',
+ ),
+ });
+ }
+
+ getApiKey() {
+ const apiKey = process.env.DALLE3_API_KEY ?? process.env.DALLE_API_KEY ?? '';
+ if (!apiKey && !this.override) {
+ throw new Error('Missing DALLE_API_KEY environment variable.');
+ }
+ return apiKey;
+ }
+
+ replaceUnwantedChars(inputString) {
+ return inputString
+ .replace(/\r\n|\r|\n/g, ' ')
+ .replace(/"/g, '')
+ .trim();
+ }
+
+ wrapInMarkdown(imageUrl) {
+ return ``;
+ }
+
+ returnValue(value) {
+ if (this.isAgent === true && typeof value === 'string') {
+ return [value, {}];
+ } else if (this.isAgent === true && typeof value === 'object') {
+ return [displayMessage, value];
+ }
+
+ return value;
+ }
+
+ async _call(data) {
+ const { prompt, quality = 'standard', size = '1024x1024', style = 'vivid' } = data;
+ if (!prompt) {
+ throw new Error('Missing required field: prompt');
+ }
+
+ let resp;
+ try {
+ resp = await this.openai.images.generate({
+ model: 'dall-e-3',
+ quality,
+ style,
+ size,
+ prompt: this.replaceUnwantedChars(prompt),
+ n: 1,
+ });
+ } catch (error) {
+ logger.error('[DALL-E-3] Problem generating the image:', error);
+ return this
+ .returnValue(`Something went wrong when trying to generate the image. The DALL-E API may be unavailable:
+Error Message: ${error.message}`);
+ }
+
+ if (!resp) {
+ return this.returnValue(
+ 'Something went wrong when trying to generate the image. The DALL-E API may be unavailable',
+ );
+ }
+
+ const theImageUrl = resp.data[0].url;
+
+ if (!theImageUrl) {
+ return this.returnValue(
+ 'No image URL returned from OpenAI API. There may be a problem with the API or your configuration.',
+ );
+ }
+
+ if (this.isAgent) {
+ let fetchOptions = {};
+ if (process.env.PROXY) {
+ const proxyAgent = new ProxyAgent(process.env.PROXY);
+ fetchOptions.dispatcher = proxyAgent;
+ }
+ const imageResponse = await fetch(theImageUrl, fetchOptions);
+ const arrayBuffer = await imageResponse.arrayBuffer();
+ const base64 = Buffer.from(arrayBuffer).toString('base64');
+ const content = [
+ {
+ type: ContentTypes.IMAGE_URL,
+ image_url: {
+ url: `data:image/png;base64,${base64}`,
+ },
+ },
+ ];
+
+ const response = [
+ {
+ type: ContentTypes.TEXT,
+ text: displayMessage,
+ },
+ ];
+ return [response, { content }];
+ }
+
+ const imageBasename = getImageBasename(theImageUrl);
+ const imageExt = path.extname(imageBasename);
+
+ const extension = imageExt.startsWith('.') ? imageExt.slice(1) : imageExt;
+ const imageName = `img-${uuidv4()}.${extension}`;
+
+ logger.debug('[DALL-E-3]', {
+ imageName,
+ imageBasename,
+ imageExt,
+ extension,
+ theImageUrl,
+ data: resp.data[0],
+ });
+
+ try {
+ const result = await this.processFileURL({
+ URL: theImageUrl,
+ basePath: 'images',
+ userId: this.userId,
+ fileName: imageName,
+ fileStrategy: this.fileStrategy,
+ context: FileContext.image_generation,
+ });
+
+ if (this.returnMetadata) {
+ this.result = result;
+ } else {
+ this.result = this.wrapInMarkdown(result.filepath);
+ }
+ } catch (error) {
+ logger.error('Error while saving the image:', error);
+ this.result = `Failed to save the image locally. ${error.message}`;
+ }
+
+ return this.returnValue(this.result);
+ }
+}
+
+module.exports = DALLE3;
diff --git a/api/app/clients/tools/structured/FluxAPI.js b/api/app/clients/tools/structured/FluxAPI.js
new file mode 100644
index 0000000000..9fa08a0343
--- /dev/null
+++ b/api/app/clients/tools/structured/FluxAPI.js
@@ -0,0 +1,554 @@
+const { z } = require('zod');
+const axios = require('axios');
+const fetch = require('node-fetch');
+const { v4: uuidv4 } = require('uuid');
+const { Tool } = require('@langchain/core/tools');
+const { logger } = require('@librechat/data-schemas');
+const { HttpsProxyAgent } = require('https-proxy-agent');
+const { FileContext, ContentTypes } = require('librechat-data-provider');
+
+const displayMessage =
+ "Flux 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.";
+
+/**
+ * FluxAPI - A tool for generating high-quality images from text prompts using the Flux API.
+ * Each call generates one image. If multiple images are needed, make multiple consecutive calls with the same or varied prompts.
+ */
+class FluxAPI extends Tool {
+ // Pricing constants in USD per image
+ static PRICING = {
+ FLUX_PRO_1_1_ULTRA: -0.06, // /v1/flux-pro-1.1-ultra
+ FLUX_PRO_1_1: -0.04, // /v1/flux-pro-1.1
+ FLUX_PRO: -0.05, // /v1/flux-pro
+ FLUX_DEV: -0.025, // /v1/flux-dev
+ FLUX_PRO_FINETUNED: -0.06, // /v1/flux-pro-finetuned
+ FLUX_PRO_1_1_ULTRA_FINETUNED: -0.07, // /v1/flux-pro-1.1-ultra-finetuned
+ };
+
+ constructor(fields = {}) {
+ super();
+
+ /** @type {boolean} Used to initialize the Tool without necessary variables. */
+ this.override = fields.override ?? false;
+
+ this.userId = fields.userId;
+ this.fileStrategy = fields.fileStrategy;
+
+ /** @type {boolean} **/
+ this.isAgent = fields.isAgent;
+ this.returnMetadata = fields.returnMetadata ?? false;
+
+ if (fields.processFileURL) {
+ /** @type {processFileURL} Necessary for output to contain all image metadata. */
+ this.processFileURL = fields.processFileURL.bind(this);
+ }
+
+ this.apiKey = fields.FLUX_API_KEY || this.getApiKey();
+
+ this.name = 'flux';
+ this.description =
+ 'Use Flux to generate images from text descriptions. This tool can generate images and list available finetunes. Each generate call creates one image. For multiple images, make multiple consecutive calls.';
+
+ this.description_for_model = `// Transform any image description into a detailed, high-quality prompt. Never submit a prompt under 3 sentences. Follow these core rules:
+ // 1. ALWAYS enhance basic prompts into 5-10 detailed sentences (e.g., "a cat" becomes: "A close-up photo of a sleek Siamese cat with piercing blue eyes. The cat sits elegantly on a vintage leather armchair, its tail curled gracefully around its paws. Warm afternoon sunlight streams through a nearby window, casting gentle shadows across its face and highlighting the subtle variations in its cream and chocolate-point fur. The background is softly blurred, creating a shallow depth of field that draws attention to the cat's expressive features. The overall composition has a peaceful, contemplative mood with a professional photography style.")
+ // 2. Each prompt MUST be 3-6 descriptive sentences minimum, focusing on visual elements: lighting, composition, mood, and style
+ // Use action: 'list_finetunes' to see available custom models. When using finetunes, use endpoint: '/v1/flux-pro-finetuned' (default) or '/v1/flux-pro-1.1-ultra-finetuned' for higher quality and aspect ratio.`;
+
+ // Add base URL from environment variable with fallback
+ this.baseUrl = process.env.FLUX_API_BASE_URL || 'https://api.us1.bfl.ai';
+
+ // Define the schema for structured input
+ this.schema = z.object({
+ action: z
+ .enum(['generate', 'list_finetunes', 'generate_finetuned'])
+ .default('generate')
+ .describe(
+ 'Action to perform: "generate" for image generation, "generate_finetuned" for finetuned model generation, "list_finetunes" to get available custom models',
+ ),
+ prompt: z
+ .string()
+ .optional()
+ .describe(
+ 'Text prompt for image generation. Required when action is "generate". Not used for list_finetunes.',
+ ),
+ width: z
+ .number()
+ .optional()
+ .describe(
+ 'Width of the generated image in pixels. Must be a multiple of 32. Default is 1024.',
+ ),
+ height: z
+ .number()
+ .optional()
+ .describe(
+ 'Height of the generated image in pixels. Must be a multiple of 32. Default is 768.',
+ ),
+ prompt_upsampling: z
+ .boolean()
+ .optional()
+ .default(false)
+ .describe('Whether to perform upsampling on the prompt.'),
+ steps: z
+ .number()
+ .int()
+ .optional()
+ .describe('Number of steps to run the model for, a number from 1 to 50. Default is 40.'),
+ seed: z.number().optional().describe('Optional seed for reproducibility.'),
+ safety_tolerance: z
+ .number()
+ .optional()
+ .default(6)
+ .describe(
+ 'Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict.',
+ ),
+ endpoint: z
+ .enum([
+ '/v1/flux-pro-1.1',
+ '/v1/flux-pro',
+ '/v1/flux-dev',
+ '/v1/flux-pro-1.1-ultra',
+ '/v1/flux-pro-finetuned',
+ '/v1/flux-pro-1.1-ultra-finetuned',
+ ])
+ .optional()
+ .default('/v1/flux-pro-1.1')
+ .describe('Endpoint to use for image generation.'),
+ raw: z
+ .boolean()
+ .optional()
+ .default(false)
+ .describe(
+ 'Generate less processed, more natural-looking images. Only works for /v1/flux-pro-1.1-ultra.',
+ ),
+ finetune_id: z.string().optional().describe('ID of the finetuned model to use'),
+ finetune_strength: z
+ .number()
+ .optional()
+ .default(1.1)
+ .describe('Strength of the finetuning effect (typically between 0.1 and 1.2)'),
+ guidance: z.number().optional().default(2.5).describe('Guidance scale for finetuned models'),
+ aspect_ratio: z
+ .string()
+ .optional()
+ .default('16:9')
+ .describe('Aspect ratio for ultra models (e.g., "16:9")'),
+ });
+ }
+
+ getAxiosConfig() {
+ const config = {};
+ if (process.env.PROXY) {
+ config.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
+ }
+ return config;
+ }
+
+ /** @param {Object|string} value */
+ getDetails(value) {
+ if (typeof value === 'string') {
+ return value;
+ }
+ return JSON.stringify(value, null, 2);
+ }
+
+ getApiKey() {
+ const apiKey = process.env.FLUX_API_KEY || '';
+ if (!apiKey && !this.override) {
+ throw new Error('Missing FLUX_API_KEY environment variable.');
+ }
+ return apiKey;
+ }
+
+ wrapInMarkdown(imageUrl) {
+ const serverDomain = process.env.DOMAIN_SERVER || 'http://localhost:3080';
+ return ``;
+ }
+
+ returnValue(value) {
+ if (this.isAgent === true && typeof value === 'string') {
+ return [value, {}];
+ } else if (this.isAgent === true && typeof value === 'object') {
+ if (Array.isArray(value)) {
+ return value;
+ }
+ return [displayMessage, value];
+ }
+ return value;
+ }
+
+ async _call(data) {
+ const { action = 'generate', ...imageData } = data;
+
+ // Use provided API key for this request if available, otherwise use default
+ const requestApiKey = this.apiKey || this.getApiKey();
+
+ // Handle list_finetunes action
+ if (action === 'list_finetunes') {
+ return this.getMyFinetunes(requestApiKey);
+ }
+
+ // Handle finetuned generation
+ if (action === 'generate_finetuned') {
+ return this.generateFinetunedImage(imageData, requestApiKey);
+ }
+
+ // For generate action, ensure prompt is provided
+ if (!imageData.prompt) {
+ throw new Error('Missing required field: prompt');
+ }
+
+ let payload = {
+ prompt: imageData.prompt,
+ prompt_upsampling: imageData.prompt_upsampling || false,
+ safety_tolerance: imageData.safety_tolerance || 6,
+ output_format: imageData.output_format || 'png',
+ };
+
+ // Add optional parameters if provided
+ if (imageData.width) {
+ payload.width = imageData.width;
+ }
+ if (imageData.height) {
+ payload.height = imageData.height;
+ }
+ if (imageData.steps) {
+ payload.steps = imageData.steps;
+ }
+ if (imageData.seed !== undefined) {
+ payload.seed = imageData.seed;
+ }
+ if (imageData.raw) {
+ payload.raw = imageData.raw;
+ }
+
+ const generateUrl = `${this.baseUrl}${imageData.endpoint || '/v1/flux-pro'}`;
+ const resultUrl = `${this.baseUrl}/v1/get_result`;
+
+ logger.debug('[FluxAPI] Generating image with payload:', payload);
+ logger.debug('[FluxAPI] Using endpoint:', generateUrl);
+
+ let taskResponse;
+ try {
+ taskResponse = await axios.post(generateUrl, payload, {
+ headers: {
+ 'x-key': requestApiKey,
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ },
+ ...this.getAxiosConfig(),
+ });
+ } catch (error) {
+ const details = this.getDetails(error?.response?.data || error.message);
+ logger.error('[FluxAPI] Error while submitting task:', details);
+
+ return this.returnValue(
+ `Something went wrong when trying to generate the image. The Flux API may be unavailable:
+ Error Message: ${details}`,
+ );
+ }
+
+ const taskId = taskResponse.data.id;
+
+ // Polling for the result
+ let status = 'Pending';
+ let resultData = null;
+ while (status !== 'Ready' && status !== 'Error') {
+ try {
+ // Wait 2 seconds between polls
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ const resultResponse = await axios.get(resultUrl, {
+ headers: {
+ 'x-key': requestApiKey,
+ Accept: 'application/json',
+ },
+ params: { id: taskId },
+ ...this.getAxiosConfig(),
+ });
+ status = resultResponse.data.status;
+
+ if (status === 'Ready') {
+ resultData = resultResponse.data.result;
+ break;
+ } else if (status === 'Error') {
+ logger.error('[FluxAPI] Error in task:', resultResponse.data);
+ return this.returnValue('An error occurred during image generation.');
+ }
+ } catch (error) {
+ const details = this.getDetails(error?.response?.data || error.message);
+ logger.error('[FluxAPI] Error while getting result:', details);
+ return this.returnValue('An error occurred while retrieving the image.');
+ }
+ }
+
+ // If no result data
+ if (!resultData || !resultData.sample) {
+ logger.error('[FluxAPI] No image data received from API. Response:', resultData);
+ return this.returnValue('No image data received from Flux API.');
+ }
+
+ // Try saving the image locally
+ const imageUrl = resultData.sample;
+ const imageName = `img-${uuidv4()}.png`;
+
+ if (this.isAgent) {
+ try {
+ // Fetch the image and convert to base64
+ const fetchOptions = {};
+ if (process.env.PROXY) {
+ fetchOptions.agent = new HttpsProxyAgent(process.env.PROXY);
+ }
+ const imageResponse = await fetch(imageUrl, fetchOptions);
+ const arrayBuffer = await imageResponse.arrayBuffer();
+ const base64 = Buffer.from(arrayBuffer).toString('base64');
+ const content = [
+ {
+ type: ContentTypes.IMAGE_URL,
+ image_url: {
+ url: `data:image/png;base64,${base64}`,
+ },
+ },
+ ];
+
+ const response = [
+ {
+ type: ContentTypes.TEXT,
+ text: displayMessage,
+ },
+ ];
+ return [response, { content }];
+ } catch (error) {
+ logger.error('Error processing image for agent:', error);
+ return this.returnValue(`Failed to process the image. ${error.message}`);
+ }
+ }
+
+ try {
+ logger.debug('[FluxAPI] Saving image:', imageUrl);
+ const result = await this.processFileURL({
+ fileStrategy: this.fileStrategy,
+ userId: this.userId,
+ URL: imageUrl,
+ fileName: imageName,
+ basePath: 'images',
+ context: FileContext.image_generation,
+ });
+
+ logger.debug('[FluxAPI] Image saved to path:', result.filepath);
+
+ // Calculate cost based on endpoint
+ /**
+ * TODO: Cost handling
+ const endpoint = imageData.endpoint || '/v1/flux-pro';
+ const endpointKey = Object.entries(FluxAPI.PRICING).find(([key, _]) =>
+ endpoint.includes(key.toLowerCase().replace(/_/g, '-')),
+ )?.[0];
+ const cost = FluxAPI.PRICING[endpointKey] || 0;
+ */
+ this.result = this.returnMetadata ? result : this.wrapInMarkdown(result.filepath);
+ return this.returnValue(this.result);
+ } catch (error) {
+ const details = this.getDetails(error?.message ?? 'No additional error details.');
+ logger.error('Error while saving the image:', details);
+ return this.returnValue(`Failed to save the image locally. ${details}`);
+ }
+ }
+
+ async getMyFinetunes(apiKey = null) {
+ const finetunesUrl = `${this.baseUrl}/v1/my_finetunes`;
+ const detailsUrl = `${this.baseUrl}/v1/finetune_details`;
+
+ try {
+ const headers = {
+ 'x-key': apiKey || this.getApiKey(),
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ };
+
+ // Get list of finetunes
+ const response = await axios.get(finetunesUrl, {
+ headers,
+ ...this.getAxiosConfig(),
+ });
+ const finetunes = response.data.finetunes;
+
+ // Fetch details for each finetune
+ const finetuneDetails = await Promise.all(
+ finetunes.map(async (finetuneId) => {
+ try {
+ const detailResponse = await axios.get(`${detailsUrl}?finetune_id=${finetuneId}`, {
+ headers,
+ ...this.getAxiosConfig(),
+ });
+ return {
+ id: finetuneId,
+ ...detailResponse.data,
+ };
+ } catch (error) {
+ logger.error(`[FluxAPI] Error fetching details for finetune ${finetuneId}:`, error);
+ return {
+ id: finetuneId,
+ error: 'Failed to fetch details',
+ };
+ }
+ }),
+ );
+
+ if (this.isAgent) {
+ const formattedDetails = JSON.stringify(finetuneDetails, null, 2);
+ return [`Here are the available finetunes:\n${formattedDetails}`, null];
+ }
+ return JSON.stringify(finetuneDetails);
+ } catch (error) {
+ const details = this.getDetails(error?.response?.data || error.message);
+ logger.error('[FluxAPI] Error while getting finetunes:', details);
+ const errorMsg = `Failed to get finetunes: ${details}`;
+ return this.isAgent ? this.returnValue([errorMsg, {}]) : new Error(errorMsg);
+ }
+ }
+
+ async generateFinetunedImage(imageData, requestApiKey) {
+ if (!imageData.prompt) {
+ throw new Error('Missing required field: prompt');
+ }
+
+ if (!imageData.finetune_id) {
+ throw new Error(
+ 'Missing required field: finetune_id for finetuned generation. Please supply a finetune_id!',
+ );
+ }
+
+ // Validate endpoint is appropriate for finetuned generation
+ const validFinetunedEndpoints = ['/v1/flux-pro-finetuned', '/v1/flux-pro-1.1-ultra-finetuned'];
+ const endpoint = imageData.endpoint || '/v1/flux-pro-finetuned';
+
+ if (!validFinetunedEndpoints.includes(endpoint)) {
+ throw new Error(
+ `Invalid endpoint for finetuned generation. Must be one of: ${validFinetunedEndpoints.join(', ')}`,
+ );
+ }
+
+ let payload = {
+ prompt: imageData.prompt,
+ prompt_upsampling: imageData.prompt_upsampling || false,
+ safety_tolerance: imageData.safety_tolerance || 6,
+ output_format: imageData.output_format || 'png',
+ finetune_id: imageData.finetune_id,
+ finetune_strength: imageData.finetune_strength || 1.0,
+ guidance: imageData.guidance || 2.5,
+ };
+
+ // Add optional parameters if provided
+ if (imageData.width) {
+ payload.width = imageData.width;
+ }
+ if (imageData.height) {
+ payload.height = imageData.height;
+ }
+ if (imageData.steps) {
+ payload.steps = imageData.steps;
+ }
+ if (imageData.seed !== undefined) {
+ payload.seed = imageData.seed;
+ }
+ if (imageData.raw) {
+ payload.raw = imageData.raw;
+ }
+
+ const generateUrl = `${this.baseUrl}${endpoint}`;
+ const resultUrl = `${this.baseUrl}/v1/get_result`;
+
+ logger.debug('[FluxAPI] Generating finetuned image with payload:', payload);
+ logger.debug('[FluxAPI] Using endpoint:', generateUrl);
+
+ let taskResponse;
+ try {
+ taskResponse = await axios.post(generateUrl, payload, {
+ headers: {
+ 'x-key': requestApiKey,
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ },
+ ...this.getAxiosConfig(),
+ });
+ } catch (error) {
+ const details = this.getDetails(error?.response?.data || error.message);
+ logger.error('[FluxAPI] Error while submitting finetuned task:', details);
+ return this.returnValue(
+ `Something went wrong when trying to generate the finetuned image. The Flux API may be unavailable:
+ Error Message: ${details}`,
+ );
+ }
+
+ const taskId = taskResponse.data.id;
+
+ // Polling for the result
+ let status = 'Pending';
+ let resultData = null;
+ while (status !== 'Ready' && status !== 'Error') {
+ try {
+ // Wait 2 seconds between polls
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ const resultResponse = await axios.get(resultUrl, {
+ headers: {
+ 'x-key': requestApiKey,
+ Accept: 'application/json',
+ },
+ params: { id: taskId },
+ ...this.getAxiosConfig(),
+ });
+ status = resultResponse.data.status;
+
+ if (status === 'Ready') {
+ resultData = resultResponse.data.result;
+ break;
+ } else if (status === 'Error') {
+ logger.error('[FluxAPI] Error in finetuned task:', resultResponse.data);
+ return this.returnValue('An error occurred during finetuned image generation.');
+ }
+ } catch (error) {
+ const details = this.getDetails(error?.response?.data || error.message);
+ logger.error('[FluxAPI] Error while getting finetuned result:', details);
+ return this.returnValue('An error occurred while retrieving the finetuned image.');
+ }
+ }
+
+ // If no result data
+ if (!resultData || !resultData.sample) {
+ logger.error('[FluxAPI] No image data received from API. Response:', resultData);
+ return this.returnValue('No image data received from Flux API.');
+ }
+
+ // Try saving the image locally
+ const imageUrl = resultData.sample;
+ const imageName = `img-${uuidv4()}.png`;
+
+ try {
+ logger.debug('[FluxAPI] Saving finetuned image:', imageUrl);
+ const result = await this.processFileURL({
+ fileStrategy: this.fileStrategy,
+ userId: this.userId,
+ URL: imageUrl,
+ fileName: imageName,
+ basePath: 'images',
+ context: FileContext.image_generation,
+ });
+
+ logger.debug('[FluxAPI] Finetuned image saved to path:', result.filepath);
+
+ // Calculate cost based on endpoint
+ const endpointKey = endpoint.includes('ultra')
+ ? 'FLUX_PRO_1_1_ULTRA_FINETUNED'
+ : 'FLUX_PRO_FINETUNED';
+ const cost = FluxAPI.PRICING[endpointKey] || 0;
+ // Return the result based on returnMetadata flag
+ this.result = this.returnMetadata ? result : this.wrapInMarkdown(result.filepath);
+ return this.returnValue(this.result);
+ } catch (error) {
+ const details = this.getDetails(error?.message ?? 'No additional error details.');
+ logger.error('Error while saving the finetuned image:', details);
+ return this.returnValue(`Failed to save the finetuned image locally. ${details}`);
+ }
+ }
+}
+
+module.exports = FluxAPI;
diff --git a/api/app/clients/tools/structured/GoogleSearch.js b/api/app/clients/tools/structured/GoogleSearch.js
new file mode 100644
index 0000000000..d703d56f83
--- /dev/null
+++ b/api/app/clients/tools/structured/GoogleSearch.js
@@ -0,0 +1,72 @@
+const { z } = require('zod');
+const { Tool } = require('@langchain/core/tools');
+const { getEnvironmentVariable } = require('@langchain/core/utils/env');
+
+class GoogleSearchResults extends Tool {
+ static lc_name() {
+ return 'google';
+ }
+
+ constructor(fields = {}) {
+ super(fields);
+ this.name = 'google';
+ this.envVarApiKey = 'GOOGLE_SEARCH_API_KEY';
+ this.envVarSearchEngineId = 'GOOGLE_CSE_ID';
+ this.override = fields.override ?? false;
+ this.apiKey = fields[this.envVarApiKey] ?? getEnvironmentVariable(this.envVarApiKey);
+ this.searchEngineId =
+ fields[this.envVarSearchEngineId] ?? getEnvironmentVariable(this.envVarSearchEngineId);
+
+ if (!this.override && (!this.apiKey || !this.searchEngineId)) {
+ throw new Error(
+ `Missing ${this.envVarApiKey} or ${this.envVarSearchEngineId} environment variable.`,
+ );
+ }
+
+ this.kwargs = fields?.kwargs ?? {};
+ this.name = 'google';
+ this.description =
+ 'A search engine optimized for comprehensive, accurate, and trusted results. Useful for when you need to answer questions about current events.';
+
+ this.schema = z.object({
+ query: z.string().min(1).describe('The search query string.'),
+ max_results: z
+ .number()
+ .min(1)
+ .max(10)
+ .optional()
+ .describe('The maximum number of search results to return. Defaults to 10.'),
+ // Note: Google API has its own parameters for search customization, adjust as needed.
+ });
+ }
+
+ async _call(input) {
+ const validationResult = this.schema.safeParse(input);
+ if (!validationResult.success) {
+ throw new Error(`Validation failed: ${JSON.stringify(validationResult.error.issues)}`);
+ }
+
+ const { query, max_results = 5 } = validationResult.data;
+
+ const response = await fetch(
+ `https://www.googleapis.com/customsearch/v1?key=${this.apiKey}&cx=${
+ this.searchEngineId
+ }&q=${encodeURIComponent(query)}&num=${max_results}`,
+ {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+ );
+
+ const json = await response.json();
+ if (!response.ok) {
+ throw new Error(`Request failed with status ${response.status}: ${json.error.message}`);
+ }
+
+ return JSON.stringify(json);
+ }
+}
+
+module.exports = GoogleSearchResults;
diff --git a/api/app/clients/tools/structured/OpenAIImageTools.js b/api/app/clients/tools/structured/OpenAIImageTools.js
new file mode 100644
index 0000000000..3771167c51
--- /dev/null
+++ b/api/app/clients/tools/structured/OpenAIImageTools.js
@@ -0,0 +1,411 @@
+const axios = require('axios');
+const { v4 } = require('uuid');
+const OpenAI = require('openai');
+const FormData = require('form-data');
+const { ProxyAgent } = require('undici');
+const { tool } = require('@langchain/core/tools');
+const { logger } = require('@librechat/data-schemas');
+const { HttpsProxyAgent } = require('https-proxy-agent');
+const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
+const { logAxiosError, oaiToolkit, extractBaseURL } = require('@librechat/api');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+const { getFiles } = require('~/models');
+
+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;
+}
+
+function createAbortHandler() {
+ return function () {
+ logger.debug('[ImageGenOAI] Image generation aborted');
+ };
+}
+
+/**
+ * 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
+ * @param {string} [fields.imageOutputType] - The image output type configuration
+ * @param {string} [fields.fileStrategy] - The file storage strategy
+ * @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 = fields.imageOutputType || EImageOutputType.PNG;
+ const appFileStrategy = fields.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) {
+ const proxyAgent = new ProxyAgent(process.env.PROXY);
+ clientConfig.fetchOptions = {
+ dispatcher: proxyAgent,
+ };
+ }
+
+ /** @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;
+ /** @type {AbortSignal} */
+ let derivedSignal = null;
+ /** @type {() => void} */
+ let abortHandler = null;
+
+ try {
+ if (runnableConfig?.signal) {
+ derivedSignal = AbortSignal.any([runnableConfig.signal]);
+ abortHandler = createAbortHandler();
+ derivedSignal.addEventListener('abort', abortHandler, { once: true });
+ }
+
+ resp = await openai.images.generate(
+ {
+ model: 'gpt-image-1',
+ 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}`);
+ } finally {
+ if (abortHandler && derivedSignal) {
+ derivedSignal.removeEventListener('abort', abortHandler);
+ }
+ }
+
+ 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 }];
+ },
+ oaiToolkit.image_gen_oai,
+ );
+
+ /**
+ * 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) {
+ const proxyAgent = new ProxyAgent(process.env.PROXY);
+ clientConfig.fetchOptions = {
+ dispatcher: proxyAgent,
+ };
+ }
+
+ 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>} */
+ 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} */
+ let stream;
+ /** @type {NodeStreamDownloader} */
+ 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}`;
+ }
+
+ /** @type {AbortSignal} */
+ let derivedSignal = null;
+ /** @type {() => void} */
+ let abortHandler = null;
+
+ try {
+ if (runnableConfig?.signal) {
+ derivedSignal = AbortSignal.any([runnableConfig.signal]);
+ abortHandler = createAbortHandler();
+ derivedSignal.addEventListener('abort', abortHandler, { once: true });
+ }
+
+ /** @type {import('axios').AxiosRequestConfig} */
+ const axiosConfig = {
+ headers,
+ ...clientConfig,
+ signal: derivedSignal,
+ baseURL,
+ };
+
+ if (process.env.PROXY) {
+ axiosConfig.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
+ }
+
+ 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'}`);
+ } finally {
+ if (abortHandler && derivedSignal) {
+ derivedSignal.removeEventListener('abort', abortHandler);
+ }
+ }
+ },
+ oaiToolkit.image_edit_oai,
+ );
+
+ return [imageGenTool, imageEditTool];
+}
+
+module.exports = createOpenAIImageTools;
diff --git a/api/app/clients/tools/structured/OpenWeather.js b/api/app/clients/tools/structured/OpenWeather.js
new file mode 100644
index 0000000000..f92fe522ce
--- /dev/null
+++ b/api/app/clients/tools/structured/OpenWeather.js
@@ -0,0 +1,317 @@
+const { Tool } = require('@langchain/core/tools');
+const { z } = require('zod');
+const { getEnvironmentVariable } = require('@langchain/core/utils/env');
+const fetch = require('node-fetch');
+
+/**
+ * Map user-friendly units to OpenWeather units.
+ * Defaults to Celsius if not specified.
+ */
+function mapUnitsToOpenWeather(unit) {
+ if (!unit) {
+ return 'metric';
+ } // Default to Celsius
+ switch (unit) {
+ case 'Celsius':
+ return 'metric';
+ case 'Kelvin':
+ return 'standard';
+ case 'Fahrenheit':
+ return 'imperial';
+ default:
+ return 'metric'; // fallback
+ }
+}
+
+/**
+ * Recursively round temperature fields in the API response.
+ */
+function roundTemperatures(obj) {
+ const tempKeys = new Set([
+ 'temp',
+ 'feels_like',
+ 'dew_point',
+ 'day',
+ 'min',
+ 'max',
+ 'night',
+ 'eve',
+ 'morn',
+ 'afternoon',
+ 'morning',
+ 'evening',
+ ]);
+
+ if (Array.isArray(obj)) {
+ return obj.map((item) => roundTemperatures(item));
+ } else if (obj && typeof obj === 'object') {
+ for (const key of Object.keys(obj)) {
+ const value = obj[key];
+ if (value && typeof value === 'object') {
+ obj[key] = roundTemperatures(value);
+ } else if (typeof value === 'number' && tempKeys.has(key)) {
+ obj[key] = Math.round(value);
+ }
+ }
+ }
+ return obj;
+}
+
+class OpenWeather extends Tool {
+ name = 'open_weather';
+ description =
+ 'Provides weather data from OpenWeather One Call API 3.0. ' +
+ 'Actions: help, current_forecast, timestamp, daily_aggregation, overview. ' +
+ 'If lat/lon not provided, specify "city" for geocoding. ' +
+ 'Units: "Celsius", "Kelvin", or "Fahrenheit" (default: Celsius). ' +
+ 'For timestamp action, use "date" in YYYY-MM-DD format.';
+
+ schema = z.object({
+ action: z.enum(['help', 'current_forecast', 'timestamp', 'daily_aggregation', 'overview']),
+ city: z.string().optional(),
+ lat: z.number().optional(),
+ lon: z.number().optional(),
+ exclude: z.string().optional(),
+ units: z.enum(['Celsius', 'Kelvin', 'Fahrenheit']).optional(),
+ lang: z.string().optional(),
+ date: z.string().optional(), // For timestamp and daily_aggregation
+ tz: z.string().optional(),
+ });
+
+ constructor(fields = {}) {
+ super();
+ this.envVar = 'OPENWEATHER_API_KEY';
+ this.override = fields.override ?? false;
+ this.apiKey = fields[this.envVar] ?? this.getApiKey();
+ }
+
+ getApiKey() {
+ const key = getEnvironmentVariable(this.envVar);
+ if (!key && !this.override) {
+ throw new Error(`Missing ${this.envVar} environment variable.`);
+ }
+ return key;
+ }
+
+ async geocodeCity(city) {
+ const geocodeUrl = `https://api.openweathermap.org/geo/1.0/direct?q=${encodeURIComponent(
+ city,
+ )}&limit=1&appid=${this.apiKey}`;
+ const res = await fetch(geocodeUrl);
+ const data = await res.json();
+ if (!res.ok || !Array.isArray(data) || data.length === 0) {
+ throw new Error(`Could not find coordinates for city: ${city}`);
+ }
+ return { lat: data[0].lat, lon: data[0].lon };
+ }
+
+ convertDateToUnix(dateStr) {
+ const parts = dateStr.split('-');
+ if (parts.length !== 3) {
+ throw new Error('Invalid date format. Expected YYYY-MM-DD.');
+ }
+ const year = parseInt(parts[0], 10);
+ const month = parseInt(parts[1], 10);
+ const day = parseInt(parts[2], 10);
+ if (isNaN(year) || isNaN(month) || isNaN(day)) {
+ throw new Error('Invalid date format. Expected YYYY-MM-DD with valid numbers.');
+ }
+
+ const dateObj = new Date(Date.UTC(year, month - 1, day, 0, 0, 0));
+ if (isNaN(dateObj.getTime())) {
+ throw new Error('Invalid date provided. Cannot parse into a valid date.');
+ }
+
+ return Math.floor(dateObj.getTime() / 1000);
+ }
+
+ async _call(args) {
+ try {
+ const { action, city, lat, lon, exclude, units, lang, date, tz } = args;
+ const owmUnits = mapUnitsToOpenWeather(units);
+
+ if (action === 'help') {
+ return JSON.stringify(
+ {
+ title: 'OpenWeather One Call API 3.0 Help',
+ description: 'Guidance on using the OpenWeather One Call API 3.0.',
+ endpoints: {
+ current_and_forecast: {
+ endpoint: 'data/3.0/onecall',
+ data_provided: [
+ 'Current weather',
+ 'Minute forecast (1h)',
+ 'Hourly forecast (48h)',
+ 'Daily forecast (8 days)',
+ 'Government weather alerts',
+ ],
+ required_params: [['lat', 'lon'], ['city']],
+ optional_params: ['exclude', 'units (Celsius/Kelvin/Fahrenheit)', 'lang'],
+ usage_example: {
+ city: 'Knoxville, Tennessee',
+ units: 'Fahrenheit',
+ lang: 'en',
+ },
+ },
+ weather_for_timestamp: {
+ endpoint: 'data/3.0/onecall/timemachine',
+ data_provided: [
+ 'Historical weather (since 1979-01-01)',
+ 'Future forecast up to 4 days ahead',
+ ],
+ required_params: [
+ ['lat', 'lon', 'date (YYYY-MM-DD)'],
+ ['city', 'date (YYYY-MM-DD)'],
+ ],
+ optional_params: ['units (Celsius/Kelvin/Fahrenheit)', 'lang'],
+ usage_example: {
+ city: 'Knoxville, Tennessee',
+ date: '2020-03-04',
+ units: 'Fahrenheit',
+ lang: 'en',
+ },
+ },
+ daily_aggregation: {
+ endpoint: 'data/3.0/onecall/day_summary',
+ data_provided: [
+ 'Aggregated weather data for a specific date (1979-01-02 to 1.5 years ahead)',
+ ],
+ required_params: [
+ ['lat', 'lon', 'date (YYYY-MM-DD)'],
+ ['city', 'date (YYYY-MM-DD)'],
+ ],
+ optional_params: ['units (Celsius/Kelvin/Fahrenheit)', 'lang', 'tz'],
+ usage_example: {
+ city: 'Knoxville, Tennessee',
+ date: '2020-03-04',
+ units: 'Celsius',
+ lang: 'en',
+ },
+ },
+ weather_overview: {
+ endpoint: 'data/3.0/onecall/overview',
+ data_provided: ['Human-readable weather summary (today/tomorrow)'],
+ required_params: [['lat', 'lon'], ['city']],
+ optional_params: ['date (YYYY-MM-DD)', 'units (Celsius/Kelvin/Fahrenheit)'],
+ usage_example: {
+ city: 'Knoxville, Tennessee',
+ date: '2024-05-13',
+ units: 'Celsius',
+ },
+ },
+ },
+ notes: [
+ 'If lat/lon not provided, you can specify a city name and it will be geocoded.',
+ 'For the timestamp action, provide a date in YYYY-MM-DD format instead of a Unix timestamp.',
+ 'By default, temperatures are returned in Celsius.',
+ 'You can specify units as Celsius, Kelvin, or Fahrenheit.',
+ 'All temperatures are rounded to the nearest degree.',
+ ],
+ errors: [
+ '400: Bad Request (missing/invalid params)',
+ '401: Unauthorized (check API key)',
+ '404: Not Found (no data or city)',
+ '429: Too many requests',
+ '5xx: Internal error',
+ ],
+ },
+ null,
+ 2,
+ );
+ }
+
+ let finalLat = lat;
+ let finalLon = lon;
+
+ // If lat/lon not provided but city is given, geocode it
+ if ((finalLat == null || finalLon == null) && city) {
+ const coords = await this.geocodeCity(city);
+ finalLat = coords.lat;
+ finalLon = coords.lon;
+ }
+
+ if (['current_forecast', 'timestamp', 'daily_aggregation', 'overview'].includes(action)) {
+ if (typeof finalLat !== 'number' || typeof finalLon !== 'number') {
+ return "Error: lat and lon are required and must be numbers for this action (or specify 'city').";
+ }
+ }
+
+ const baseUrl = 'https://api.openweathermap.org/data/3.0';
+ let endpoint = '';
+ const params = new URLSearchParams({ appid: this.apiKey, units: owmUnits });
+
+ let dt;
+ if (action === 'timestamp') {
+ if (!date) {
+ return "Error: For timestamp action, a 'date' in YYYY-MM-DD format is required.";
+ }
+ dt = this.convertDateToUnix(date);
+ }
+
+ if (action === 'daily_aggregation' && !date) {
+ return 'Error: date (YYYY-MM-DD) is required for daily_aggregation action.';
+ }
+
+ switch (action) {
+ case 'current_forecast':
+ endpoint = '/onecall';
+ params.append('lat', String(finalLat));
+ params.append('lon', String(finalLon));
+ if (exclude) {
+ params.append('exclude', exclude);
+ }
+ if (lang) {
+ params.append('lang', lang);
+ }
+ break;
+ case 'timestamp':
+ endpoint = '/onecall/timemachine';
+ params.append('lat', String(finalLat));
+ params.append('lon', String(finalLon));
+ params.append('dt', String(dt));
+ if (lang) {
+ params.append('lang', lang);
+ }
+ break;
+ case 'daily_aggregation':
+ endpoint = '/onecall/day_summary';
+ params.append('lat', String(finalLat));
+ params.append('lon', String(finalLon));
+ params.append('date', date);
+ if (lang) {
+ params.append('lang', lang);
+ }
+ if (tz) {
+ params.append('tz', tz);
+ }
+ break;
+ case 'overview':
+ endpoint = '/onecall/overview';
+ params.append('lat', String(finalLat));
+ params.append('lon', String(finalLon));
+ if (date) {
+ params.append('date', date);
+ }
+ break;
+ default:
+ return `Error: Unknown action: ${action}`;
+ }
+
+ const url = `${baseUrl}${endpoint}?${params.toString()}`;
+ const response = await fetch(url);
+ const json = await response.json();
+ if (!response.ok) {
+ return `Error: OpenWeather API request failed with status ${response.status}: ${
+ json.message || JSON.stringify(json)
+ }`;
+ }
+
+ const roundedJson = roundTemperatures(json);
+ return JSON.stringify(roundedJson);
+ } catch (err) {
+ return `Error: ${err.message}`;
+ }
+ }
+}
+
+module.exports = OpenWeather;
diff --git a/api/app/clients/tools/structured/StableDiffusion.js b/api/app/clients/tools/structured/StableDiffusion.js
new file mode 100644
index 0000000000..3a1ea831d3
--- /dev/null
+++ b/api/app/clients/tools/structured/StableDiffusion.js
@@ -0,0 +1,196 @@
+// Generates image using stable diffusion webui's api (automatic1111)
+const fs = require('fs');
+const { z } = require('zod');
+const path = require('path');
+const axios = require('axios');
+const sharp = require('sharp');
+const { v4: uuidv4 } = require('uuid');
+const { Tool } = require('@langchain/core/tools');
+const { logger } = require('@librechat/data-schemas');
+const { FileContext, ContentTypes } = require('librechat-data-provider');
+const { getBasePath } = require('@librechat/api');
+const paths = require('~/config/paths');
+
+const displayMessage =
+ "Stable Diffusion 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.";
+
+class StableDiffusionAPI extends Tool {
+ constructor(fields) {
+ super();
+ /** @type {string} User ID */
+ this.userId = fields.userId;
+ /** @type {ServerRequest | undefined} Express Request object, only provided by ToolService */
+ this.req = fields.req;
+ /** @type {boolean} Used to initialize the Tool without necessary variables. */
+ this.override = fields.override ?? false;
+ /** @type {boolean} Necessary for output to contain all image metadata. */
+ this.returnMetadata = fields.returnMetadata ?? false;
+ /** @type {boolean} */
+ this.isAgent = fields.isAgent;
+ if (fields.uploadImageBuffer) {
+ /** @type {uploadImageBuffer} Necessary for output to contain all image metadata. */
+ this.uploadImageBuffer = fields.uploadImageBuffer.bind(this);
+ }
+
+ this.name = 'stable-diffusion';
+ this.url = fields.SD_WEBUI_URL || this.getServerURL();
+ this.description_for_model = `// Generate images and visuals using text.
+// Guidelines:
+// - ALWAYS use {{"prompt": "7+ detailed keywords", "negative_prompt": "7+ detailed keywords"}} structure for queries.
+// - ALWAYS include the markdown url in your final response to show the user: }/images/id.png)
+// - Visually describe the moods, details, structures, styles, and/or proportions of the image. Remember, the focus is on visual attributes.
+// - Craft your input by "showing" and not "telling" the imagery. Think in terms of what you'd want to see in a photograph or a painting.
+// - Here's an example for generating a realistic portrait photo of a man:
+// "prompt":"photo of a man in black clothes, half body, high detailed skin, coastline, overcast weather, wind, waves, 8k uhd, dslr, soft lighting, high quality, film grain, Fujifilm XT3"
+// "negative_prompt":"semi-realistic, cgi, 3d, render, sketch, cartoon, drawing, anime, out of frame, low quality, ugly, mutation, deformed"
+// - Generate images only once per human query unless explicitly requested by the user`;
+ this.description =
+ "You can generate images using text with 'stable-diffusion'. This tool is exclusively for visual content.";
+ this.schema = z.object({
+ prompt: z
+ .string()
+ .describe(
+ 'Detailed keywords to describe the subject, using at least 7 keywords to accurately describe the image, separated by comma',
+ ),
+ negative_prompt: z
+ .string()
+ .describe(
+ 'Keywords we want to exclude from the final image, using at least 7 keywords to accurately describe the image, separated by comma',
+ ),
+ });
+ }
+
+ replaceNewLinesWithSpaces(inputString) {
+ return inputString.replace(/\r\n|\r|\n/g, ' ');
+ }
+
+ getMarkdownImageUrl(imageName) {
+ const imageUrl = path
+ .join(this.relativePath, this.userId, imageName)
+ .replace(/\\/g, '/')
+ .replace('public/', '');
+ return ``;
+ }
+
+ returnValue(value) {
+ if (this.isAgent === true && typeof value === 'string') {
+ return [value, {}];
+ } else if (this.isAgent === true && typeof value === 'object') {
+ return [displayMessage, value];
+ }
+
+ return value;
+ }
+
+ getServerURL() {
+ const url = process.env.SD_WEBUI_URL || '';
+ if (!url && !this.override) {
+ throw new Error('Missing SD_WEBUI_URL environment variable.');
+ }
+ return url;
+ }
+
+ async _call(data) {
+ const url = this.url;
+ const { prompt, negative_prompt } = data;
+ const payload = {
+ prompt,
+ negative_prompt,
+ cfg_scale: 4.5,
+ steps: 22,
+ width: 1024,
+ height: 1024,
+ };
+ let generationResponse;
+ try {
+ generationResponse = await axios.post(`${url}/sdapi/v1/txt2img`, payload);
+ } catch (error) {
+ logger.error('[StableDiffusion] Error while generating image:', error);
+ return 'Error making API request.';
+ }
+ const image = generationResponse.data.images[0];
+
+ /** @type {{ height: number, width: number, seed: number, infotexts: string[] }} */
+ let info = {};
+ try {
+ info = JSON.parse(generationResponse.data.info);
+ } catch (error) {
+ logger.error('[StableDiffusion] Error while getting image metadata:', error);
+ }
+
+ const file_id = uuidv4();
+ const imageName = `${file_id}.png`;
+ const { imageOutput: imageOutputPath, clientPath } = paths;
+ const filepath = path.join(imageOutputPath, this.userId, imageName);
+ this.relativePath = path.relative(clientPath, imageOutputPath);
+
+ if (!fs.existsSync(path.join(imageOutputPath, this.userId))) {
+ fs.mkdirSync(path.join(imageOutputPath, this.userId), { recursive: true });
+ }
+
+ try {
+ if (this.isAgent) {
+ const content = [
+ {
+ type: ContentTypes.IMAGE_URL,
+ image_url: {
+ url: `data:image/png;base64,${image}`,
+ },
+ },
+ ];
+
+ const response = [
+ {
+ type: ContentTypes.TEXT,
+ text: displayMessage,
+ },
+ ];
+ return [response, { content }];
+ }
+
+ const buffer = Buffer.from(image.split(',', 1)[0], 'base64');
+ if (this.returnMetadata && this.uploadImageBuffer && this.req) {
+ const file = await this.uploadImageBuffer({
+ req: this.req,
+ context: FileContext.image_generation,
+ resize: false,
+ metadata: {
+ buffer,
+ height: info.height,
+ width: info.width,
+ bytes: Buffer.byteLength(buffer),
+ filename: imageName,
+ type: 'image/png',
+ file_id,
+ },
+ });
+
+ const generationInfo = info.infotexts[0].split('\n').pop();
+ return {
+ ...file,
+ prompt,
+ metadata: {
+ negative_prompt,
+ seed: info.seed,
+ info: generationInfo,
+ },
+ };
+ }
+
+ await sharp(buffer)
+ .withMetadata({
+ iptcpng: {
+ parameters: info.infotexts[0],
+ },
+ })
+ .toFile(filepath);
+ this.result = this.getMarkdownImageUrl(imageName);
+ } catch (error) {
+ logger.error('[StableDiffusion] Error while saving the image:', error);
+ }
+
+ return this.returnValue(this.result);
+ }
+}
+
+module.exports = StableDiffusionAPI;
diff --git a/api/app/clients/tools/structured/TavilySearch.js b/api/app/clients/tools/structured/TavilySearch.js
new file mode 100644
index 0000000000..55f3b6e1c8
--- /dev/null
+++ b/api/app/clients/tools/structured/TavilySearch.js
@@ -0,0 +1,77 @@
+const { z } = require('zod');
+const { ProxyAgent, fetch } = require('undici');
+const { tool } = require('@langchain/core/tools');
+const { getApiKey } = require('./credentials');
+
+function createTavilySearchTool(fields = {}) {
+ const envVar = 'TAVILY_API_KEY';
+ const override = fields.override ?? false;
+ const apiKey = fields.apiKey ?? getApiKey(envVar, override);
+ const kwargs = fields?.kwargs ?? {};
+
+ return tool(
+ async (input) => {
+ const { query, ...rest } = input;
+
+ const requestBody = {
+ api_key: apiKey,
+ query,
+ ...rest,
+ ...kwargs,
+ };
+
+ const fetchOptions = {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(requestBody),
+ };
+
+ if (process.env.PROXY) {
+ fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY);
+ }
+
+ const response = await fetch('https://api.tavily.com/search', fetchOptions);
+
+ const json = await response.json();
+ if (!response.ok) {
+ throw new Error(`Request failed with status ${response.status}: ${json.error}`);
+ }
+
+ return JSON.stringify(json);
+ },
+ {
+ name: 'tavily_search_results_json',
+ description:
+ 'A search engine optimized for comprehensive, accurate, and trusted results. Useful for when you need to answer questions about current events.',
+ schema: z.object({
+ query: z.string().min(1).describe('The search query string.'),
+ max_results: z
+ .number()
+ .min(1)
+ .max(10)
+ .optional()
+ .describe('The maximum number of search results to return. Defaults to 5.'),
+ search_depth: z
+ .enum(['basic', 'advanced'])
+ .optional()
+ .describe(
+ 'The depth of the search, affecting result quality and response time (`basic` or `advanced`). Default is basic for quick results and advanced for indepth high quality results but longer response time. Advanced calls equals 2 requests.',
+ ),
+ include_images: z
+ .boolean()
+ .optional()
+ .describe(
+ 'Whether to include a list of query-related images in the response. Default is False.',
+ ),
+ include_answer: z
+ .boolean()
+ .optional()
+ .describe('Whether to include answers in the search results. Default is False.'),
+ }),
+ },
+ );
+}
+
+module.exports = createTavilySearchTool;
diff --git a/api/app/clients/tools/structured/TavilySearchResults.js b/api/app/clients/tools/structured/TavilySearchResults.js
new file mode 100644
index 0000000000..796f31dcca
--- /dev/null
+++ b/api/app/clients/tools/structured/TavilySearchResults.js
@@ -0,0 +1,131 @@
+const { z } = require('zod');
+const { ProxyAgent, fetch } = require('undici');
+const { Tool } = require('@langchain/core/tools');
+const { getEnvironmentVariable } = require('@langchain/core/utils/env');
+
+class TavilySearchResults extends Tool {
+ static lc_name() {
+ return 'TavilySearchResults';
+ }
+
+ constructor(fields = {}) {
+ super(fields);
+ this.envVar = 'TAVILY_API_KEY';
+ /* Used to initialize the Tool without necessary variables. */
+ this.override = fields.override ?? false;
+ this.apiKey = fields[this.envVar] ?? this.getApiKey();
+
+ this.kwargs = fields?.kwargs ?? {};
+ this.name = 'tavily_search_results_json';
+ this.description =
+ 'A search engine optimized for comprehensive, accurate, and trusted results. Useful for when you need to answer questions about current events.';
+
+ this.schema = z.object({
+ query: z.string().min(1).describe('The search query string.'),
+ max_results: z
+ .number()
+ .min(1)
+ .max(10)
+ .optional()
+ .describe('The maximum number of search results to return. Defaults to 5.'),
+ search_depth: z
+ .enum(['basic', 'advanced'])
+ .optional()
+ .describe(
+ 'The depth of the search, affecting result quality and response time (`basic` or `advanced`). Default is basic for quick results and advanced for indepth high quality results but longer response time. Advanced calls equals 2 requests.',
+ ),
+ include_images: z
+ .boolean()
+ .optional()
+ .describe(
+ 'Whether to include a list of query-related images in the response. Default is False.',
+ ),
+ include_answer: z
+ .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.'),
+ 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.',
+ ),
+ });
+ }
+
+ getApiKey() {
+ const apiKey = getEnvironmentVariable(this.envVar);
+ if (!apiKey && !this.override) {
+ throw new Error(`Missing ${this.envVar} environment variable.`);
+ }
+ return apiKey;
+ }
+
+ async _call(input) {
+ const validationResult = this.schema.safeParse(input);
+ if (!validationResult.success) {
+ throw new Error(`Validation failed: ${JSON.stringify(validationResult.error.issues)}`);
+ }
+
+ const { query, ...rest } = validationResult.data;
+
+ const requestBody = {
+ api_key: this.apiKey,
+ query,
+ ...rest,
+ ...this.kwargs,
+ };
+
+ const fetchOptions = {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(requestBody),
+ };
+
+ if (process.env.PROXY) {
+ fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY);
+ }
+
+ const response = await fetch('https://api.tavily.com/search', fetchOptions);
+
+ const json = await response.json();
+ if (!response.ok) {
+ throw new Error(
+ `Request failed with status ${response.status}: ${json?.detail?.error || json?.error}`,
+ );
+ }
+
+ return JSON.stringify(json);
+ }
+}
+
+module.exports = TavilySearchResults;
diff --git a/api/app/clients/tools/structured/TraversaalSearch.js b/api/app/clients/tools/structured/TraversaalSearch.js
new file mode 100644
index 0000000000..d2ccc35c75
--- /dev/null
+++ b/api/app/clients/tools/structured/TraversaalSearch.js
@@ -0,0 +1,88 @@
+const { z } = require('zod');
+const { Tool } = require('@langchain/core/tools');
+const { logger } = require('@librechat/data-schemas');
+const { getEnvironmentVariable } = require('@langchain/core/utils/env');
+
+/**
+ * Tool for the Traversaal AI search API, Ares.
+ */
+class TraversaalSearch extends Tool {
+ static lc_name() {
+ return 'TraversaalSearch';
+ }
+ constructor(fields) {
+ super(fields);
+ this.name = 'traversaal_search';
+ this.description = `An AI search engine optimized for comprehensive, accurate, and trusted results.
+ Useful for when you need to answer questions about current events. Input should be a search query.`;
+ this.description_for_model =
+ '\'Please create a specific sentence for the AI to understand and use as a query to search the web based on the user\'s request. For example, "Find information about the highest mountains in the world." or "Show me the latest news articles about climate change and its impact on polar ice caps."\'';
+ this.schema = z.object({
+ query: z
+ .string()
+ .describe(
+ "A properly written sentence to be interpreted by an AI to search the web according to the user's request.",
+ ),
+ });
+
+ this.apiKey = fields?.TRAVERSAAL_API_KEY ?? this.getApiKey();
+ }
+
+ getApiKey() {
+ const apiKey = getEnvironmentVariable('TRAVERSAAL_API_KEY');
+ if (!apiKey && this.override) {
+ throw new Error(
+ 'No Traversaal API key found. Either set an environment variable named "TRAVERSAAL_API_KEY" or pass an API key as "apiKey".',
+ );
+ }
+ return apiKey;
+ }
+
+ async _call({ query }, _runManager) {
+ const body = {
+ query: [query],
+ };
+ try {
+ const response = await fetch('https://api-ares.traversaal.ai/live/predict', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-api-key': this.apiKey,
+ },
+ body: JSON.stringify({ ...body }),
+ });
+ const json = await response.json();
+ if (!response.ok) {
+ throw new Error(
+ `Request failed with status code ${response.status}: ${json.error ?? json.message}`,
+ );
+ }
+ if (!json.data) {
+ throw new Error('Could not parse Traversaal API results. Please try again.');
+ }
+
+ const baseText = json.data?.response_text ?? '';
+ const sources = json.data?.web_url;
+ const noResponse = 'No response found in Traversaal API results';
+
+ if (!baseText && !sources) {
+ return noResponse;
+ }
+
+ const sourcesText = sources?.length ? '\n\nSources:\n - ' + sources.join('\n - ') : '';
+
+ const result = baseText + sourcesText;
+
+ if (!result) {
+ return noResponse;
+ }
+
+ return result;
+ } catch (error) {
+ logger.error('Traversaal API request failed', error);
+ return `Traversaal API request failed: ${error.message}`;
+ }
+ }
+}
+
+module.exports = TraversaalSearch;
diff --git a/api/app/clients/tools/structured/Wolfram.js b/api/app/clients/tools/structured/Wolfram.js
new file mode 100644
index 0000000000..1f7fe6b1b7
--- /dev/null
+++ b/api/app/clients/tools/structured/Wolfram.js
@@ -0,0 +1,95 @@
+/* eslint-disable no-useless-escape */
+const { z } = require('zod');
+const axios = require('axios');
+const { Tool } = require('@langchain/core/tools');
+const { logger } = require('@librechat/data-schemas');
+
+class WolframAlphaAPI extends Tool {
+ constructor(fields) {
+ super();
+ /* Used to initialize the Tool without necessary variables. */
+ this.override = fields.override ?? false;
+
+ this.name = 'wolfram';
+ this.apiKey = fields.WOLFRAM_APP_ID || this.getAppId();
+ this.description_for_model = `// Access dynamic computation and curated data from WolframAlpha and Wolfram Cloud.
+ // General guidelines:
+ // - Use only getWolframAlphaResults or getWolframCloudResults endpoints.
+ // - Prefer getWolframAlphaResults unless Wolfram Language code should be evaluated.
+ // - Use getWolframAlphaResults for natural-language queries in English; translate non-English queries before sending, then respond in the original language.
+ // - Use getWolframCloudResults for problems solvable with Wolfram Language code.
+ // - Suggest only Wolfram Language for external computation.
+ // - Inform users if information is not from Wolfram endpoints.
+ // - Display image URLs with Image Markdown syntax: . You must prefix the caption brackets with "!".
+ // - ALWAYS use this exponent notation: \`6*10^14\`, NEVER \`6e14\`.
+ // - ALWAYS use {{"input": query}} structure for queries to Wolfram endpoints; \`query\` must ONLY be a single-line string.
+ // - ALWAYS use proper Markdown formatting for all math, scientific, and chemical formulas, symbols, etc.: '$$\n[expression]\n$$' for standalone cases and '\( [expression] \)' when inline.
+ // - Format inline Wolfram Language code with Markdown code formatting.
+ // - Never mention your knowledge cutoff date; Wolfram may return more recent data. getWolframAlphaResults guidelines:
+ // - Understands natural language queries about entities in chemistry, physics, geography, history, art, astronomy, and more.
+ // - Performs mathematical calculations, date and unit conversions, formula solving, etc.
+ // - Convert inputs to simplified keyword queries whenever possible (e.g. convert "how many people live in France" to "France population").
+ // - Use ONLY single-letter variable names, with or without integer subscript (e.g., n, n1, n_1).
+ // - Use named physical constants (e.g., 'speed of light') without numerical substitution.
+ // - Include a space between compound units (e.g., "Ω m" for "ohm*meter").
+ // - To solve for a variable in an equation with units, consider solving a corresponding equation without units; exclude counting units (e.g., books), include genuine units (e.g., kg).
+ // - If data for multiple properties is needed, make separate calls for each property.
+ // - If a Wolfram Alpha result is not relevant to the query:
+ // -- If Wolfram provides multiple 'Assumptions' for a query, choose the more relevant one(s) without explaining the initial result. If you are unsure, ask the user to choose.
+ // -- Re-send the exact same 'input' with NO modifications, and add the 'assumption' parameter, formatted as a list, with the relevant values.
+ // -- ONLY simplify or rephrase the initial query if a more relevant 'Assumption' or other input suggestions are not provided.
+ // -- Do not explain each step unless user input is needed. Proceed directly to making a better API call based on the available assumptions.`;
+ this.description = `WolframAlpha offers computation, math, curated knowledge, and real-time data. It handles natural language queries and performs complex calculations.
+ Follow the guidelines to get the best results.`;
+ this.schema = z.object({
+ input: z.string().describe('Natural language query to WolframAlpha following the guidelines'),
+ });
+ }
+
+ async fetchRawText(url) {
+ try {
+ const response = await axios.get(url, { responseType: 'text' });
+ return response.data;
+ } catch (error) {
+ logger.error('[WolframAlphaAPI] Error fetching raw text:', error);
+ throw error;
+ }
+ }
+
+ getAppId() {
+ const appId = process.env.WOLFRAM_APP_ID || '';
+ if (!appId && !this.override) {
+ throw new Error('Missing WOLFRAM_APP_ID environment variable.');
+ }
+ return appId;
+ }
+
+ createWolframAlphaURL(query) {
+ // Clean up query
+ const formattedQuery = query.replaceAll(/`/g, '').replaceAll(/\n/g, ' ');
+ const baseURL = 'https://www.wolframalpha.com/api/v1/llm-api';
+ const encodedQuery = encodeURIComponent(formattedQuery);
+ const appId = this.apiKey || this.getAppId();
+ const url = `${baseURL}?input=${encodedQuery}&appid=${appId}`;
+ return url;
+ }
+
+ async _call(data) {
+ try {
+ const { input } = data;
+ const url = this.createWolframAlphaURL(input);
+ const response = await this.fetchRawText(url);
+ return response;
+ } catch (error) {
+ if (error.response && error.response.data) {
+ logger.error('[WolframAlphaAPI] Error data:', error);
+ return error.response.data;
+ } else {
+ logger.error('[WolframAlphaAPI] Error querying Wolfram Alpha', error);
+ return 'There was an error querying Wolfram Alpha.';
+ }
+ }
+ }
+}
+
+module.exports = WolframAlphaAPI;
diff --git a/api/app/clients/tools/structured/YouTube.js b/api/app/clients/tools/structured/YouTube.js
new file mode 100644
index 0000000000..8d1c7b9ff9
--- /dev/null
+++ b/api/app/clients/tools/structured/YouTube.js
@@ -0,0 +1,137 @@
+const { ytToolkit } = require('@librechat/api');
+const { tool } = require('@langchain/core/tools');
+const { youtube } = require('@googleapis/youtube');
+const { logger } = require('@librechat/data-schemas');
+const { YoutubeTranscript } = require('youtube-transcript');
+const { getApiKey } = require('./credentials');
+
+function extractVideoId(url) {
+ const rawIdRegex = /^[a-zA-Z0-9_-]{11}$/;
+ if (rawIdRegex.test(url)) {
+ return url;
+ }
+
+ const regex = new RegExp(
+ '(?:youtu\\.be/|youtube(?:\\.com)?/(?:' +
+ '(?:watch\\?v=)|(?:embed/)|(?:shorts/)|(?:live/)|(?:v/)|(?:/))?)' +
+ '([a-zA-Z0-9_-]{11})(?:\\S+)?$',
+ );
+ const match = url.match(regex);
+ return match ? match[1] : null;
+}
+
+function parseTranscript(transcriptResponse) {
+ if (!Array.isArray(transcriptResponse)) {
+ return '';
+ }
+
+ return transcriptResponse
+ .map((entry) => entry.text.trim())
+ .filter((text) => text)
+ .join(' ')
+ .replaceAll(''', "'");
+}
+
+function createYouTubeTools(fields = {}) {
+ const envVar = 'YOUTUBE_API_KEY';
+ const override = fields.override ?? false;
+ const apiKey = fields.apiKey ?? fields[envVar] ?? getApiKey(envVar, override);
+
+ const youtubeClient = youtube({
+ version: 'v3',
+ auth: apiKey,
+ });
+
+ const searchTool = tool(async ({ query, maxResults = 5 }) => {
+ const response = await youtubeClient.search.list({
+ part: 'snippet',
+ q: query,
+ type: 'video',
+ maxResults: maxResults || 5,
+ });
+ const result = response.data.items.map((item) => ({
+ title: item.snippet.title,
+ description: item.snippet.description,
+ url: `https://www.youtube.com/watch?v=${item.id.videoId}`,
+ }));
+ return JSON.stringify(result, null, 2);
+ }, ytToolkit.youtube_search);
+
+ const infoTool = tool(async ({ url }) => {
+ const videoId = extractVideoId(url);
+ if (!videoId) {
+ throw new Error('Invalid YouTube URL or video ID');
+ }
+
+ const response = await youtubeClient.videos.list({
+ part: 'snippet,statistics',
+ id: videoId,
+ });
+
+ if (!response.data.items?.length) {
+ throw new Error('Video not found');
+ }
+ const video = response.data.items[0];
+
+ const result = {
+ title: video.snippet.title,
+ description: video.snippet.description,
+ views: video.statistics.viewCount,
+ likes: video.statistics.likeCount,
+ comments: video.statistics.commentCount,
+ };
+ return JSON.stringify(result, null, 2);
+ }, ytToolkit.youtube_info);
+
+ const commentsTool = tool(async ({ url, maxResults = 10 }) => {
+ const videoId = extractVideoId(url);
+ if (!videoId) {
+ throw new Error('Invalid YouTube URL or video ID');
+ }
+
+ const response = await youtubeClient.commentThreads.list({
+ part: 'snippet',
+ videoId,
+ maxResults: maxResults || 10,
+ });
+
+ const result = response.data.items.map((item) => ({
+ author: item.snippet.topLevelComment.snippet.authorDisplayName,
+ text: item.snippet.topLevelComment.snippet.textDisplay,
+ likes: item.snippet.topLevelComment.snippet.likeCount,
+ }));
+ return JSON.stringify(result, null, 2);
+ }, ytToolkit.youtube_comments);
+
+ const transcriptTool = tool(async ({ url }) => {
+ const videoId = extractVideoId(url);
+ if (!videoId) {
+ throw new Error('Invalid YouTube URL or video ID');
+ }
+
+ try {
+ try {
+ const transcript = await YoutubeTranscript.fetchTranscript(videoId, { lang: 'en' });
+ return parseTranscript(transcript);
+ } catch (e) {
+ logger.error(e);
+ }
+
+ try {
+ const transcript = await YoutubeTranscript.fetchTranscript(videoId, { lang: 'de' });
+ return parseTranscript(transcript);
+ } catch (e) {
+ logger.error(e);
+ }
+
+ const transcript = await YoutubeTranscript.fetchTranscript(videoId);
+ return parseTranscript(transcript);
+ } catch (error) {
+ throw new Error(`Failed to fetch transcript: ${error.message}`);
+ }
+ }, ytToolkit.youtube_transcript);
+
+ return [searchTool, infoTool, commentsTool, transcriptTool];
+}
+
+module.exports = createYouTubeTools;
diff --git a/api/app/clients/tools/structured/credentials.js b/api/app/clients/tools/structured/credentials.js
new file mode 100644
index 0000000000..fbcce6fbf5
--- /dev/null
+++ b/api/app/clients/tools/structured/credentials.js
@@ -0,0 +1,13 @@
+const { getEnvironmentVariable } = require('@langchain/core/utils/env');
+
+function getApiKey(envVar, override) {
+ const key = getEnvironmentVariable(envVar);
+ if (!key && !override) {
+ throw new Error(`Missing ${envVar} environment variable.`);
+ }
+ return key;
+}
+
+module.exports = {
+ getApiKey,
+};
diff --git a/api/app/clients/tools/structured/specs/DALLE3-proxy.spec.js b/api/app/clients/tools/structured/specs/DALLE3-proxy.spec.js
new file mode 100644
index 0000000000..4481a7d70f
--- /dev/null
+++ b/api/app/clients/tools/structured/specs/DALLE3-proxy.spec.js
@@ -0,0 +1,60 @@
+const DALLE3 = require('../DALLE3');
+const { ProxyAgent } = require('undici');
+
+jest.mock('tiktoken');
+const processFileURL = jest.fn();
+
+describe('DALLE3 Proxy Configuration', () => {
+ let originalEnv;
+
+ beforeAll(() => {
+ originalEnv = { ...process.env };
+ });
+
+ beforeEach(() => {
+ jest.resetModules();
+ process.env = { ...originalEnv };
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ it('should configure ProxyAgent in fetchOptions.dispatcher when PROXY env is set', () => {
+ // Set proxy environment variable
+ process.env.PROXY = 'http://proxy.example.com:8080';
+ process.env.DALLE_API_KEY = 'test-api-key';
+
+ // Create instance
+ const dalleWithProxy = new DALLE3({ processFileURL });
+
+ // Check that the openai client exists
+ expect(dalleWithProxy.openai).toBeDefined();
+
+ // Check that _options exists and has fetchOptions with a dispatcher
+ expect(dalleWithProxy.openai._options).toBeDefined();
+ expect(dalleWithProxy.openai._options.fetchOptions).toBeDefined();
+ expect(dalleWithProxy.openai._options.fetchOptions.dispatcher).toBeDefined();
+ expect(dalleWithProxy.openai._options.fetchOptions.dispatcher).toBeInstanceOf(ProxyAgent);
+ });
+
+ it('should not configure ProxyAgent when PROXY env is not set', () => {
+ // Ensure PROXY is not set
+ delete process.env.PROXY;
+ process.env.DALLE_API_KEY = 'test-api-key';
+
+ // Create instance
+ const dalleWithoutProxy = new DALLE3({ processFileURL });
+
+ // Check that the openai client exists
+ expect(dalleWithoutProxy.openai).toBeDefined();
+
+ // Check that _options exists but fetchOptions either doesn't exist or doesn't have a dispatcher
+ expect(dalleWithoutProxy.openai._options).toBeDefined();
+
+ // fetchOptions should either not exist or not have a dispatcher
+ if (dalleWithoutProxy.openai._options.fetchOptions) {
+ expect(dalleWithoutProxy.openai._options.fetchOptions.dispatcher).toBeUndefined();
+ }
+ });
+});
diff --git a/api/app/clients/tools/structured/specs/DALLE3.spec.js b/api/app/clients/tools/structured/specs/DALLE3.spec.js
new file mode 100644
index 0000000000..d2040989f9
--- /dev/null
+++ b/api/app/clients/tools/structured/specs/DALLE3.spec.js
@@ -0,0 +1,216 @@
+const OpenAI = require('openai');
+const { logger } = require('@librechat/data-schemas');
+const DALLE3 = require('../DALLE3');
+
+jest.mock('openai');
+jest.mock('@librechat/data-schemas', () => {
+ return {
+ logger: {
+ info: jest.fn(),
+ warn: jest.fn(),
+ debug: jest.fn(),
+ error: jest.fn(),
+ },
+ };
+});
+
+jest.mock('tiktoken', () => {
+ return {
+ encoding_for_model: jest.fn().mockReturnValue({
+ encode: jest.fn(),
+ decode: jest.fn(),
+ }),
+ };
+});
+
+const processFileURL = jest.fn();
+
+const generate = jest.fn();
+OpenAI.mockImplementation(() => ({
+ images: {
+ generate,
+ },
+}));
+
+jest.mock('fs', () => {
+ return {
+ existsSync: jest.fn(),
+ mkdirSync: jest.fn(),
+ promises: {
+ writeFile: jest.fn(),
+ readFile: jest.fn(),
+ unlink: jest.fn(),
+ },
+ };
+});
+
+jest.mock('path', () => {
+ return {
+ resolve: jest.fn(),
+ join: jest.fn(),
+ relative: jest.fn(),
+ extname: jest.fn().mockImplementation((filename) => {
+ return filename.slice(filename.lastIndexOf('.'));
+ }),
+ };
+});
+
+describe('DALLE3', () => {
+ let originalEnv;
+ let dalle; // Keep this declaration if you need to use dalle in other tests
+ const mockApiKey = 'mock_api_key';
+
+ beforeAll(() => {
+ // Save the original process.env
+ originalEnv = { ...process.env };
+ });
+
+ beforeEach(() => {
+ // Reset the process.env before each test
+ jest.resetModules();
+ process.env = { ...originalEnv, DALLE_API_KEY: mockApiKey };
+ // Instantiate DALLE3 for tests that do not depend on DALLE3_SYSTEM_PROMPT
+ dalle = new DALLE3({ processFileURL });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ // Restore the original process.env after each test
+ process.env = originalEnv;
+ });
+
+ it('should throw an error if all potential API keys are missing', () => {
+ delete process.env.DALLE3_API_KEY;
+ delete process.env.DALLE_API_KEY;
+ expect(() => new DALLE3()).toThrow('Missing DALLE_API_KEY environment variable.');
+ });
+
+ it('should replace unwanted characters in input string', () => {
+ const input = 'This is a test\nstring with "quotes" and new lines.';
+ const expectedOutput = 'This is a test string with quotes and new lines.';
+ expect(dalle.replaceUnwantedChars(input)).toBe(expectedOutput);
+ });
+
+ it('should generate markdown image URL correctly', () => {
+ const imageName = 'test.png';
+ const markdownImage = dalle.wrapInMarkdown(imageName);
+ expect(markdownImage).toBe('');
+ });
+
+ it('should call OpenAI API with correct parameters', async () => {
+ const mockData = {
+ prompt: 'A test prompt',
+ quality: 'standard',
+ size: '1024x1024',
+ style: 'vivid',
+ };
+
+ const mockResponse = {
+ data: [
+ {
+ url: 'http://example.com/img-test.png',
+ },
+ ],
+ };
+
+ generate.mockResolvedValue(mockResponse);
+ processFileURL.mockResolvedValue({
+ filepath: 'http://example.com/img-test.png',
+ });
+
+ const result = await dalle._call(mockData);
+
+ expect(generate).toHaveBeenCalledWith({
+ model: 'dall-e-3',
+ quality: mockData.quality,
+ style: mockData.style,
+ size: mockData.size,
+ prompt: mockData.prompt,
+ n: 1,
+ });
+
+ expect(result).toContain('![generated image]');
+ });
+
+ it('should use the system prompt if provided', () => {
+ process.env.DALLE3_SYSTEM_PROMPT = 'System prompt for testing';
+ jest.resetModules(); // This will ensure the module is fresh and will read the new env var
+ const DALLE3 = require('../DALLE3'); // Re-require after setting the env var
+ const dalleWithSystemPrompt = new DALLE3();
+ expect(dalleWithSystemPrompt.description_for_model).toBe('System prompt for testing');
+ });
+
+ it('should not use the system prompt if not provided', async () => {
+ delete process.env.DALLE3_SYSTEM_PROMPT;
+ const dalleWithoutSystemPrompt = new DALLE3();
+ expect(dalleWithoutSystemPrompt.description_for_model).not.toBe('System prompt for testing');
+ });
+
+ it('should throw an error if prompt is missing', async () => {
+ const mockData = {
+ quality: 'standard',
+ size: '1024x1024',
+ style: 'vivid',
+ };
+ await expect(dalle._call(mockData)).rejects.toThrow('Missing required field: prompt');
+ });
+
+ it('should log appropriate debug values', async () => {
+ const mockData = {
+ prompt: 'A test prompt',
+ };
+ const mockResponse = {
+ data: [
+ {
+ url: 'http://example.com/invalid-url',
+ },
+ ],
+ };
+
+ generate.mockResolvedValue(mockResponse);
+ await dalle._call(mockData);
+ expect(logger.debug).toHaveBeenCalledWith('[DALL-E-3]', {
+ data: { url: 'http://example.com/invalid-url' },
+ theImageUrl: 'http://example.com/invalid-url',
+ extension: expect.any(String),
+ imageBasename: expect.any(String),
+ imageExt: expect.any(String),
+ imageName: expect.any(String),
+ });
+ });
+
+ it('should log an error and return the image URL if there is an error saving the image', async () => {
+ const mockData = {
+ prompt: 'A test prompt',
+ };
+ const mockResponse = {
+ data: [
+ {
+ url: 'http://example.com/img-test.png',
+ },
+ ],
+ };
+ const error = new Error('Error while saving the image');
+ generate.mockResolvedValue(mockResponse);
+ processFileURL.mockRejectedValue(error);
+ const result = await dalle._call(mockData);
+ expect(logger.error).toHaveBeenCalledWith('Error while saving the image:', error);
+ expect(result).toBe('Failed to save the image locally. Error while saving the image');
+ });
+
+ it('should handle error when saving image to Firebase Storage fails', async () => {
+ const mockData = {
+ prompt: 'A test prompt',
+ };
+ const mockImageUrl = 'http://example.com/img-test.png';
+ const mockResponse = { data: [{ url: mockImageUrl }] };
+ const error = new Error('Error while saving to Firebase');
+ generate.mockResolvedValue(mockResponse);
+ processFileURL.mockRejectedValue(error);
+
+ const result = await dalle._call(mockData);
+
+ expect(logger.error).toHaveBeenCalledWith('Error while saving the image:', error);
+ expect(result).toContain('Failed to save the image');
+ });
+});
diff --git a/api/app/clients/tools/structured/specs/GoogleSearch.spec.js b/api/app/clients/tools/structured/specs/GoogleSearch.spec.js
new file mode 100644
index 0000000000..ff11265301
--- /dev/null
+++ b/api/app/clients/tools/structured/specs/GoogleSearch.spec.js
@@ -0,0 +1,50 @@
+const GoogleSearch = require('../GoogleSearch');
+
+jest.mock('node-fetch');
+jest.mock('@langchain/core/utils/env');
+
+describe('GoogleSearch', () => {
+ let originalEnv;
+ const mockApiKey = 'mock_api';
+ const mockSearchEngineId = 'mock_search_engine_id';
+
+ beforeAll(() => {
+ originalEnv = { ...process.env };
+ });
+
+ beforeEach(() => {
+ jest.resetModules();
+ process.env = {
+ ...originalEnv,
+ GOOGLE_SEARCH_API_KEY: mockApiKey,
+ GOOGLE_CSE_ID: mockSearchEngineId,
+ };
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ process.env = originalEnv;
+ });
+
+ it('should use mockApiKey and mockSearchEngineId when environment variables are not set', () => {
+ const instance = new GoogleSearch({
+ GOOGLE_SEARCH_API_KEY: mockApiKey,
+ GOOGLE_CSE_ID: mockSearchEngineId,
+ });
+ expect(instance.apiKey).toBe(mockApiKey);
+ expect(instance.searchEngineId).toBe(mockSearchEngineId);
+ });
+
+ it('should throw an error if GOOGLE_SEARCH_API_KEY or GOOGLE_CSE_ID is missing', () => {
+ delete process.env.GOOGLE_SEARCH_API_KEY;
+ expect(() => new GoogleSearch()).toThrow(
+ 'Missing GOOGLE_SEARCH_API_KEY or GOOGLE_CSE_ID environment variable.',
+ );
+
+ process.env.GOOGLE_SEARCH_API_KEY = mockApiKey;
+ delete process.env.GOOGLE_CSE_ID;
+ expect(() => new GoogleSearch()).toThrow(
+ 'Missing GOOGLE_SEARCH_API_KEY or GOOGLE_CSE_ID environment variable.',
+ );
+ });
+});
diff --git a/api/app/clients/tools/structured/specs/TavilySearchResults.spec.js b/api/app/clients/tools/structured/specs/TavilySearchResults.spec.js
new file mode 100644
index 0000000000..f37c83e30e
--- /dev/null
+++ b/api/app/clients/tools/structured/specs/TavilySearchResults.spec.js
@@ -0,0 +1,84 @@
+const { fetch, ProxyAgent } = require('undici');
+const TavilySearchResults = require('../TavilySearchResults');
+
+jest.mock('undici');
+jest.mock('@langchain/core/utils/env');
+
+describe('TavilySearchResults', () => {
+ let originalEnv;
+ const mockApiKey = 'mock_api_key';
+
+ beforeAll(() => {
+ originalEnv = { ...process.env };
+ });
+
+ beforeEach(() => {
+ jest.resetModules();
+ jest.clearAllMocks();
+ process.env = {
+ ...originalEnv,
+ TAVILY_API_KEY: mockApiKey,
+ };
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ it('should throw an error if TAVILY_API_KEY is missing', () => {
+ delete process.env.TAVILY_API_KEY;
+ expect(() => new TavilySearchResults()).toThrow('Missing TAVILY_API_KEY environment variable.');
+ });
+
+ it('should use mockApiKey when TAVILY_API_KEY is not set in the environment', () => {
+ const instance = new TavilySearchResults({
+ TAVILY_API_KEY: mockApiKey,
+ });
+ expect(instance.apiKey).toBe(mockApiKey);
+ });
+
+ describe('proxy support', () => {
+ const mockResponse = {
+ ok: true,
+ json: jest.fn().mockResolvedValue({ results: [] }),
+ };
+
+ beforeEach(() => {
+ fetch.mockResolvedValue(mockResponse);
+ });
+
+ it('should use ProxyAgent when PROXY env var is set', async () => {
+ const proxyUrl = 'http://proxy.example.com:8080';
+ process.env.PROXY = proxyUrl;
+
+ const mockProxyAgent = { type: 'proxy-agent' };
+ ProxyAgent.mockImplementation(() => mockProxyAgent);
+
+ const instance = new TavilySearchResults({ TAVILY_API_KEY: mockApiKey });
+ await instance._call({ query: 'test query' });
+
+ expect(ProxyAgent).toHaveBeenCalledWith(proxyUrl);
+ expect(fetch).toHaveBeenCalledWith(
+ 'https://api.tavily.com/search',
+ expect.objectContaining({
+ dispatcher: mockProxyAgent,
+ }),
+ );
+ });
+
+ it('should not use ProxyAgent when PROXY env var is not set', async () => {
+ delete process.env.PROXY;
+
+ const instance = new TavilySearchResults({ TAVILY_API_KEY: mockApiKey });
+ await instance._call({ query: 'test query' });
+
+ expect(ProxyAgent).not.toHaveBeenCalled();
+ expect(fetch).toHaveBeenCalledWith(
+ 'https://api.tavily.com/search',
+ expect.not.objectContaining({
+ dispatcher: expect.anything(),
+ }),
+ );
+ });
+ });
+});
diff --git a/api/app/clients/tools/structured/specs/openWeather.integration.test.js b/api/app/clients/tools/structured/specs/openWeather.integration.test.js
new file mode 100644
index 0000000000..07dd417cf1
--- /dev/null
+++ b/api/app/clients/tools/structured/specs/openWeather.integration.test.js
@@ -0,0 +1,224 @@
+// __tests__/openWeather.integration.test.js
+const OpenWeather = require('../OpenWeather');
+
+describe('OpenWeather Tool (Integration Test)', () => {
+ let tool;
+
+ beforeAll(() => {
+ tool = new OpenWeather({ override: true });
+ console.log('API Key present:', !!process.env.OPENWEATHER_API_KEY);
+ });
+
+ test('current_forecast with a real API key returns current weather', async () => {
+ // Check if API key is available
+ if (!process.env.OPENWEATHER_API_KEY) {
+ console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.');
+ return;
+ }
+
+ try {
+ const result = await tool.call({
+ action: 'current_forecast',
+ city: 'London',
+ units: 'Celsius',
+ });
+
+ console.log('Raw API response:', result);
+
+ const parsed = JSON.parse(result);
+ expect(parsed).toHaveProperty('current');
+ expect(typeof parsed.current.temp).toBe('number');
+ } catch (error) {
+ console.error('Test failed with error:', error);
+ throw error;
+ }
+ });
+
+ test('timestamp action with real API key returns historical data', async () => {
+ if (!process.env.OPENWEATHER_API_KEY) {
+ console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.');
+ return;
+ }
+
+ try {
+ // Use a date from yesterday to ensure data availability
+ const yesterday = new Date();
+ yesterday.setDate(yesterday.getDate() - 1);
+ const dateStr = yesterday.toISOString().split('T')[0];
+
+ const result = await tool.call({
+ action: 'timestamp',
+ city: 'London',
+ date: dateStr,
+ units: 'Celsius',
+ });
+
+ console.log('Timestamp API response:', result);
+
+ const parsed = JSON.parse(result);
+ expect(parsed).toHaveProperty('data');
+ expect(Array.isArray(parsed.data)).toBe(true);
+ expect(parsed.data[0]).toHaveProperty('temp');
+ } catch (error) {
+ console.error('Timestamp test failed with error:', error);
+ throw error;
+ }
+ });
+
+ test('daily_aggregation action with real API key returns aggregated data', async () => {
+ if (!process.env.OPENWEATHER_API_KEY) {
+ console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.');
+ return;
+ }
+
+ try {
+ // Use yesterday's date for aggregation
+ const yesterday = new Date();
+ yesterday.setDate(yesterday.getDate() - 1);
+ const dateStr = yesterday.toISOString().split('T')[0];
+
+ const result = await tool.call({
+ action: 'daily_aggregation',
+ city: 'London',
+ date: dateStr,
+ units: 'Celsius',
+ });
+
+ console.log('Daily aggregation API response:', result);
+
+ const parsed = JSON.parse(result);
+ expect(parsed).toHaveProperty('temperature');
+ expect(parsed.temperature).toHaveProperty('morning');
+ expect(parsed.temperature).toHaveProperty('afternoon');
+ expect(parsed.temperature).toHaveProperty('evening');
+ } catch (error) {
+ console.error('Daily aggregation test failed with error:', error);
+ throw error;
+ }
+ });
+
+ test('overview action with real API key returns weather summary', async () => {
+ if (!process.env.OPENWEATHER_API_KEY) {
+ console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.');
+ return;
+ }
+
+ try {
+ const result = await tool.call({
+ action: 'overview',
+ city: 'London',
+ units: 'Celsius',
+ });
+
+ console.log('Overview API response:', result);
+
+ const parsed = JSON.parse(result);
+ expect(parsed).toHaveProperty('weather_overview');
+ expect(typeof parsed.weather_overview).toBe('string');
+ expect(parsed.weather_overview.length).toBeGreaterThan(0);
+ expect(parsed).toHaveProperty('date');
+ expect(parsed).toHaveProperty('units');
+ expect(parsed.units).toBe('metric');
+ } catch (error) {
+ console.error('Overview test failed with error:', error);
+ throw error;
+ }
+ });
+
+ test('different temperature units return correct values', async () => {
+ if (!process.env.OPENWEATHER_API_KEY) {
+ console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.');
+ return;
+ }
+
+ try {
+ // Test Celsius
+ let result = await tool.call({
+ action: 'current_forecast',
+ city: 'London',
+ units: 'Celsius',
+ });
+ let parsed = JSON.parse(result);
+ const celsiusTemp = parsed.current.temp;
+
+ // Test Kelvin
+ result = await tool.call({
+ action: 'current_forecast',
+ city: 'London',
+ units: 'Kelvin',
+ });
+ parsed = JSON.parse(result);
+ const kelvinTemp = parsed.current.temp;
+
+ // Test Fahrenheit
+ result = await tool.call({
+ action: 'current_forecast',
+ city: 'London',
+ units: 'Fahrenheit',
+ });
+ parsed = JSON.parse(result);
+ const fahrenheitTemp = parsed.current.temp;
+
+ // Verify temperature conversions are roughly correct
+ // K = C + 273.15
+ // F = (C * 9/5) + 32
+ const celsiusToKelvin = Math.round(celsiusTemp + 273.15);
+ const celsiusToFahrenheit = Math.round((celsiusTemp * 9) / 5 + 32);
+
+ console.log('Temperature comparisons:', {
+ celsius: celsiusTemp,
+ kelvin: kelvinTemp,
+ fahrenheit: fahrenheitTemp,
+ calculatedKelvin: celsiusToKelvin,
+ calculatedFahrenheit: celsiusToFahrenheit,
+ });
+
+ // Allow for some rounding differences
+ expect(Math.abs(kelvinTemp - celsiusToKelvin)).toBeLessThanOrEqual(1);
+ expect(Math.abs(fahrenheitTemp - celsiusToFahrenheit)).toBeLessThanOrEqual(1);
+ } catch (error) {
+ console.error('Temperature units test failed with error:', error);
+ throw error;
+ }
+ });
+
+ test('language parameter returns localized data', async () => {
+ if (!process.env.OPENWEATHER_API_KEY) {
+ console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.');
+ return;
+ }
+
+ try {
+ // Test with English
+ let result = await tool.call({
+ action: 'current_forecast',
+ city: 'Paris',
+ units: 'Celsius',
+ lang: 'en',
+ });
+ let parsed = JSON.parse(result);
+ const englishDescription = parsed.current.weather[0].description;
+
+ // Test with French
+ result = await tool.call({
+ action: 'current_forecast',
+ city: 'Paris',
+ units: 'Celsius',
+ lang: 'fr',
+ });
+ parsed = JSON.parse(result);
+ const frenchDescription = parsed.current.weather[0].description;
+
+ console.log('Language comparison:', {
+ english: englishDescription,
+ french: frenchDescription,
+ });
+
+ // Verify descriptions are different (indicating translation worked)
+ expect(englishDescription).not.toBe(frenchDescription);
+ } catch (error) {
+ console.error('Language test failed with error:', error);
+ throw error;
+ }
+ });
+});
diff --git a/api/app/clients/tools/structured/specs/openweather.test.js b/api/app/clients/tools/structured/specs/openweather.test.js
new file mode 100644
index 0000000000..3340c80cc4
--- /dev/null
+++ b/api/app/clients/tools/structured/specs/openweather.test.js
@@ -0,0 +1,358 @@
+// __tests__/openweather.test.js
+const OpenWeather = require('../OpenWeather');
+const fetch = require('node-fetch');
+
+// Mock environment variable
+process.env.OPENWEATHER_API_KEY = 'test-api-key';
+
+// Mock the fetch function globally
+jest.mock('node-fetch', () => jest.fn());
+
+describe('OpenWeather Tool', () => {
+ let tool;
+
+ beforeAll(() => {
+ tool = new OpenWeather();
+ });
+
+ beforeEach(() => {
+ fetch.mockReset();
+ });
+
+ test('action=help returns help instructions', async () => {
+ const result = await tool.call({
+ action: 'help',
+ });
+
+ expect(typeof result).toBe('string');
+ const parsed = JSON.parse(result);
+ expect(parsed.title).toBe('OpenWeather One Call API 3.0 Help');
+ });
+
+ test('current_forecast with a city and successful geocoding + forecast', async () => {
+ // Mock geocoding response
+ fetch.mockImplementationOnce((url) => {
+ if (url.includes('geo/1.0/direct')) {
+ return Promise.resolve({
+ ok: true,
+ json: async () => [{ lat: 35.9606, lon: -83.9207 }],
+ });
+ }
+ return Promise.reject('Unexpected fetch call for geocoding');
+ });
+
+ // Mock forecast response
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: true,
+ json: async () => ({
+ current: { temp: 293.15, feels_like: 295.15 },
+ daily: [{ temp: { day: 293.15, night: 283.15 } }],
+ }),
+ }),
+ );
+
+ const result = await tool.call({
+ action: 'current_forecast',
+ city: 'Knoxville, Tennessee',
+ units: 'Kelvin',
+ });
+
+ const parsed = JSON.parse(result);
+ expect(parsed.current.temp).toBe(293);
+ expect(parsed.current.feels_like).toBe(295);
+ expect(parsed.daily[0].temp.day).toBe(293);
+ expect(parsed.daily[0].temp.night).toBe(283);
+ });
+
+ test('timestamp action with valid date returns mocked historical data', async () => {
+ // Mock geocoding response
+ fetch.mockImplementationOnce((url) => {
+ if (url.includes('geo/1.0/direct')) {
+ return Promise.resolve({
+ ok: true,
+ json: async () => [{ lat: 35.9606, lon: -83.9207 }],
+ });
+ }
+ return Promise.reject('Unexpected fetch call for geocoding');
+ });
+
+ // Mock historical weather response
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: true,
+ json: async () => ({
+ data: [
+ {
+ dt: 1583280000,
+ temp: 283.15,
+ feels_like: 280.15,
+ humidity: 75,
+ weather: [{ description: 'clear sky' }],
+ },
+ ],
+ }),
+ }),
+ );
+
+ const result = await tool.call({
+ action: 'timestamp',
+ city: 'Knoxville, Tennessee',
+ date: '2020-03-04',
+ units: 'Kelvin',
+ });
+
+ const parsed = JSON.parse(result);
+ expect(parsed.data[0].temp).toBe(283);
+ expect(parsed.data[0].feels_like).toBe(280);
+ });
+
+ test('daily_aggregation action returns aggregated weather data', async () => {
+ // Mock geocoding response
+ fetch.mockImplementationOnce((url) => {
+ if (url.includes('geo/1.0/direct')) {
+ return Promise.resolve({
+ ok: true,
+ json: async () => [{ lat: 35.9606, lon: -83.9207 }],
+ });
+ }
+ return Promise.reject('Unexpected fetch call for geocoding');
+ });
+
+ // Mock daily aggregation response
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: true,
+ json: async () => ({
+ date: '2020-03-04',
+ temperature: {
+ morning: 283.15,
+ afternoon: 293.15,
+ evening: 288.15,
+ },
+ humidity: {
+ morning: 75,
+ afternoon: 60,
+ evening: 70,
+ },
+ }),
+ }),
+ );
+
+ const result = await tool.call({
+ action: 'daily_aggregation',
+ city: 'Knoxville, Tennessee',
+ date: '2020-03-04',
+ units: 'Kelvin',
+ });
+
+ const parsed = JSON.parse(result);
+ expect(parsed.temperature.morning).toBe(283);
+ expect(parsed.temperature.afternoon).toBe(293);
+ expect(parsed.temperature.evening).toBe(288);
+ });
+
+ test('overview action returns weather summary', async () => {
+ // Mock geocoding response
+ fetch.mockImplementationOnce((url) => {
+ if (url.includes('geo/1.0/direct')) {
+ return Promise.resolve({
+ ok: true,
+ json: async () => [{ lat: 35.9606, lon: -83.9207 }],
+ });
+ }
+ return Promise.reject('Unexpected fetch call for geocoding');
+ });
+
+ // Mock overview response
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: true,
+ json: async () => ({
+ date: '2024-01-07',
+ lat: 35.9606,
+ lon: -83.9207,
+ tz: '+00:00',
+ units: 'metric',
+ weather_overview:
+ 'Currently, the temperature is 2°C with a real feel of -2°C. The sky is clear with moderate wind.',
+ }),
+ }),
+ );
+
+ const result = await tool.call({
+ action: 'overview',
+ city: 'Knoxville, Tennessee',
+ units: 'Celsius',
+ });
+
+ const parsed = JSON.parse(result);
+ expect(parsed).toHaveProperty('weather_overview');
+ expect(typeof parsed.weather_overview).toBe('string');
+ expect(parsed.weather_overview.length).toBeGreaterThan(0);
+ expect(parsed).toHaveProperty('date');
+ expect(parsed).toHaveProperty('units');
+ expect(parsed.units).toBe('metric');
+ });
+
+ test('temperature units are correctly converted', async () => {
+ // Mock geocoding response for all three calls
+ const geocodingMock = Promise.resolve({
+ ok: true,
+ json: async () => [{ lat: 35.9606, lon: -83.9207 }],
+ });
+
+ // Mock weather response for Kelvin
+ const kelvinMock = Promise.resolve({
+ ok: true,
+ json: async () => ({
+ current: { temp: 293.15 },
+ }),
+ });
+
+ // Mock weather response for Celsius
+ const celsiusMock = Promise.resolve({
+ ok: true,
+ json: async () => ({
+ current: { temp: 20 },
+ }),
+ });
+
+ // Mock weather response for Fahrenheit
+ const fahrenheitMock = Promise.resolve({
+ ok: true,
+ json: async () => ({
+ current: { temp: 68 },
+ }),
+ });
+
+ // Test Kelvin
+ fetch.mockImplementationOnce(() => geocodingMock).mockImplementationOnce(() => kelvinMock);
+
+ let result = await tool.call({
+ action: 'current_forecast',
+ city: 'Knoxville, Tennessee',
+ units: 'Kelvin',
+ });
+ let parsed = JSON.parse(result);
+ expect(parsed.current.temp).toBe(293);
+
+ // Test Celsius
+ fetch.mockImplementationOnce(() => geocodingMock).mockImplementationOnce(() => celsiusMock);
+
+ result = await tool.call({
+ action: 'current_forecast',
+ city: 'Knoxville, Tennessee',
+ units: 'Celsius',
+ });
+ parsed = JSON.parse(result);
+ expect(parsed.current.temp).toBe(20);
+
+ // Test Fahrenheit
+ fetch.mockImplementationOnce(() => geocodingMock).mockImplementationOnce(() => fahrenheitMock);
+
+ result = await tool.call({
+ action: 'current_forecast',
+ city: 'Knoxville, Tennessee',
+ units: 'Fahrenheit',
+ });
+ parsed = JSON.parse(result);
+ expect(parsed.current.temp).toBe(68);
+ });
+
+ test('timestamp action without a date returns an error message', async () => {
+ const result = await tool.call({
+ action: 'timestamp',
+ lat: 35.9606,
+ lon: -83.9207,
+ });
+ expect(result).toMatch(
+ /Error: For timestamp action, a 'date' in YYYY-MM-DD format is required./,
+ );
+ });
+
+ test('daily_aggregation action without a date returns an error message', async () => {
+ const result = await tool.call({
+ action: 'daily_aggregation',
+ lat: 35.9606,
+ lon: -83.9207,
+ });
+ expect(result).toMatch(/Error: date \(YYYY-MM-DD\) is required for daily_aggregation action./);
+ });
+
+ test('unknown action returns an error due to schema validation', async () => {
+ await expect(
+ tool.call({
+ action: 'unknown_action',
+ }),
+ ).rejects.toThrow(/Received tool input did not match expected schema/);
+ });
+
+ test('geocoding failure returns a descriptive error', async () => {
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: true,
+ json: async () => [],
+ }),
+ );
+
+ const result = await tool.call({
+ action: 'current_forecast',
+ city: 'NowhereCity',
+ });
+ expect(result).toMatch(/Error: Could not find coordinates for city: NowhereCity/);
+ });
+
+ test('API request failure returns an error', async () => {
+ // Mock geocoding success
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: true,
+ json: async () => [{ lat: 35.9606, lon: -83.9207 }],
+ }),
+ );
+
+ // Mock weather request failure
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: false,
+ status: 404,
+ json: async () => ({ message: 'Not found' }),
+ }),
+ );
+
+ const result = await tool.call({
+ action: 'current_forecast',
+ city: 'Knoxville, Tennessee',
+ });
+ expect(result).toMatch(/Error: OpenWeather API request failed with status 404: Not found/);
+ });
+
+ test('invalid date format returns an error', async () => {
+ // Mock geocoding response first
+ fetch.mockImplementationOnce((url) => {
+ if (url.includes('geo/1.0/direct')) {
+ return Promise.resolve({
+ ok: true,
+ json: async () => [{ lat: 35.9606, lon: -83.9207 }],
+ });
+ }
+ return Promise.reject('Unexpected fetch call for geocoding');
+ });
+
+ // Mock timestamp API response
+ fetch.mockImplementationOnce((url) => {
+ if (url.includes('onecall/timemachine')) {
+ throw new Error('Invalid date format. Expected YYYY-MM-DD.');
+ }
+ return Promise.reject('Unexpected fetch call');
+ });
+
+ const result = await tool.call({
+ action: 'timestamp',
+ city: 'Knoxville, Tennessee',
+ date: '03-04-2020', // Wrong format
+ });
+ expect(result).toMatch(/Error: Invalid date format. Expected YYYY-MM-DD./);
+ });
+});
diff --git a/api/app/clients/tools/util/fileSearch.js b/api/app/clients/tools/util/fileSearch.js
new file mode 100644
index 0000000000..d48b9b986d
--- /dev/null
+++ b/api/app/clients/tools/util/fileSearch.js
@@ -0,0 +1,196 @@
+const { z } = require('zod');
+const axios = require('axios');
+const { tool } = require('@langchain/core/tools');
+const { logger } = require('@librechat/data-schemas');
+const { generateShortLivedToken } = require('@librechat/api');
+const { Tools, EToolResources } = require('librechat-data-provider');
+const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
+const { getFiles } = require('~/models');
+
+/**
+ *
+ * @param {Object} options
+ * @param {ServerRequest} options.req
+ * @param {Agent['tool_resources']} options.tool_resources
+ * @param {string} [options.agentId] - The agent ID for file access control
+ * @returns {Promise<{
+ * files: Array<{ file_id: string; filename: string }>,
+ * toolContext: string
+ * }>}
+ */
+const primeFiles = async (options) => {
+ const { tool_resources, req, agentId } = options;
+ const file_ids = tool_resources?.[EToolResources.file_search]?.file_ids ?? [];
+ const agentResourceIds = new Set(file_ids);
+ const resourceFiles = tool_resources?.[EToolResources.file_search]?.files ?? [];
+
+ // Get all files first
+ const allFiles = (await getFiles({ file_id: { $in: file_ids } }, null, { text: 0 })) ?? [];
+
+ // Filter by access if user and agent are provided
+ let dbFiles;
+ if (req?.user?.id && agentId) {
+ dbFiles = await filterFilesByAgentAccess({
+ files: allFiles,
+ userId: req.user.id,
+ role: req.user.role,
+ agentId,
+ });
+ } else {
+ dbFiles = allFiles;
+ }
+
+ dbFiles = dbFiles.concat(resourceFiles);
+
+ let toolContext = `- Note: Semantic search is available through the ${Tools.file_search} tool but no files are currently loaded. Request the user to upload documents to search through.`;
+
+ const files = [];
+ for (let i = 0; i < dbFiles.length; i++) {
+ const file = dbFiles[i];
+ if (!file) {
+ continue;
+ }
+ if (i === 0) {
+ toolContext = `- Note: Use the ${Tools.file_search} tool to find relevant information within:`;
+ }
+ toolContext += `\n\t- ${file.filename}${
+ agentResourceIds.has(file.file_id) ? '' : ' (just attached by user)'
+ }`;
+ files.push({
+ file_id: file.file_id,
+ filename: file.filename,
+ });
+ }
+
+ return { files, toolContext };
+};
+
+/**
+ *
+ * @param {Object} options
+ * @param {string} options.userId
+ * @param {Array<{ file_id: string; filename: string }>} options.files
+ * @param {string} [options.entity_id]
+ * @param {boolean} [options.fileCitations=false] - Whether to include citation instructions
+ * @returns
+ */
+const createFileSearchTool = async ({ userId, files, entity_id, fileCitations = false }) => {
+ return tool(
+ async ({ query }) => {
+ if (files.length === 0) {
+ return ['No files to search. Instruct the user to add files for the search.', undefined];
+ }
+ const jwtToken = generateShortLivedToken(userId);
+ if (!jwtToken) {
+ return ['There was an error authenticating the file search request.', undefined];
+ }
+
+ /**
+ * @param {import('librechat-data-provider').TFile} file
+ * @returns {{ file_id: string, query: string, k: number, entity_id?: string }}
+ */
+ const createQueryBody = (file) => {
+ const body = {
+ file_id: file.file_id,
+ query,
+ k: 5,
+ };
+ if (!entity_id) {
+ return body;
+ }
+ body.entity_id = entity_id;
+ logger.debug(`[${Tools.file_search}] RAG API /query body`, body);
+ return body;
+ };
+
+ const queryPromises = files.map((file) =>
+ axios
+ .post(`${process.env.RAG_API_URL}/query`, createQueryBody(file), {
+ headers: {
+ Authorization: `Bearer ${jwtToken}`,
+ 'Content-Type': 'application/json',
+ },
+ })
+ .catch((error) => {
+ logger.error('Error encountered in `file_search` while querying file:', error);
+ return null;
+ }),
+ );
+
+ const results = await Promise.all(queryPromises);
+ const validResults = results.filter((result) => result !== null);
+
+ if (validResults.length === 0) {
+ return ['No results found or errors occurred while searching the files.', undefined];
+ }
+
+ const formattedResults = validResults
+ .flatMap((result, fileIndex) =>
+ result.data.map(([docInfo, distance]) => ({
+ filename: docInfo.metadata.source.split('/').pop(),
+ content: docInfo.page_content,
+ distance,
+ file_id: files[fileIndex]?.file_id,
+ page: docInfo.metadata.page || null,
+ })),
+ )
+ .sort((a, b) => a.distance - b.distance)
+ .slice(0, 10);
+
+ if (formattedResults.length === 0) {
+ return [
+ 'No content found in the files. The files may not have been processed correctly or you may need to refine your query.',
+ undefined,
+ ];
+ }
+
+ const formattedString = formattedResults
+ .map(
+ (result, index) =>
+ `File: ${result.filename}${
+ fileCitations ? `\nAnchor: \\ue202turn0file${index} (${result.filename})` : ''
+ }\nRelevance: ${(1.0 - result.distance).toFixed(4)}\nContent: ${result.content}\n`,
+ )
+ .join('\n---\n');
+
+ const sources = formattedResults.map((result) => ({
+ type: 'file',
+ fileId: result.file_id,
+ content: result.content,
+ fileName: result.filename,
+ relevance: 1.0 - result.distance,
+ pages: result.page ? [result.page] : [],
+ pageRelevance: result.page ? { [result.page]: 1.0 - result.distance } : {},
+ }));
+
+ return [formattedString, { [Tools.file_search]: { sources, fileCitations } }];
+ },
+ {
+ name: Tools.file_search,
+ responseFormat: 'content_and_artifact',
+ description: `Performs semantic search across attached "${Tools.file_search}" documents using natural language queries. This tool analyzes the content of uploaded files to find relevant information, quotes, and passages that best match your query. Use this to extract specific information or find relevant sections within the available documents.${
+ fileCitations
+ ? `
+
+**CITE FILE SEARCH RESULTS:**
+Use the EXACT anchor markers shown below (copy them verbatim) immediately after statements derived from file content. Reference the filename in your text:
+- File citation: "The document.pdf states that... \\ue202turn0file0"
+- Page reference: "According to report.docx... \\ue202turn0file1"
+- Multi-file: "Multiple sources confirm... \\ue200\\ue202turn0file0\\ue202turn0file1\\ue201"
+
+**CRITICAL:** Output these escape sequences EXACTLY as shown (e.g., \\ue202turn0file0). Do NOT substitute with other characters like † or similar symbols.
+**ALWAYS mention the filename in your text before the citation marker. NEVER use markdown links or footnotes.**`
+ : ''
+ }`,
+ schema: z.object({
+ query: z
+ .string()
+ .describe(
+ "A natural language query to search for relevant information in the files. Be specific and use keywords related to the information you're looking for. The query will be used for semantic similarity matching against the file contents.",
+ ),
+ }),
+ },
+ );
+};
+
+module.exports = { createFileSearchTool, primeFiles };
diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js
new file mode 100644
index 0000000000..15ccd38129
--- /dev/null
+++ b/api/app/clients/tools/util/handleTools.js
@@ -0,0 +1,491 @@
+const { logger } = require('@librechat/data-schemas');
+const {
+ EnvVar,
+ Calculator,
+ createSearchTool,
+ createCodeExecutionTool,
+} = require('@librechat/agents');
+const {
+ checkAccess,
+ createSafeUser,
+ mcpToolPattern,
+ loadWebSearchAuth,
+} = require('@librechat/api');
+const { getMCPServersRegistry } = require('~/config');
+const {
+ Tools,
+ Constants,
+ Permissions,
+ EToolResources,
+ PermissionTypes,
+ replaceSpecialVars,
+} = require('librechat-data-provider');
+const {
+ availableTools,
+ manifestToolMap,
+ // Basic Tools
+ GoogleSearchAPI,
+ // Structured Tools
+ DALLE3,
+ FluxAPI,
+ OpenWeather,
+ StructuredSD,
+ StructuredACS,
+ TraversaalSearch,
+ StructuredWolfram,
+ createYouTubeTools,
+ TavilySearchResults,
+ createOpenAIImageTools,
+} = require('../');
+const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
+const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
+const { getUserPluginAuthValue } = require('~/server/services/PluginService');
+const { createMCPTool, createMCPTools } = require('~/server/services/MCP');
+const { loadAuthValues } = require('~/server/services/Tools/credentials');
+const { getMCPServerTools } = require('~/server/services/Config');
+const { getRoleByName } = require('~/models/Role');
+
+/**
+ * Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values.
+ * Tools without required authentication or with valid authentication are considered valid.
+ *
+ * @param {Object} user The user object for whom to validate tool access.
+ * @param {Array} tools An array of tool identifiers to validate. Defaults to an empty array.
+ * @returns {Promise>} A promise that resolves to an array of valid tool identifiers.
+ */
+const validateTools = async (user, tools = []) => {
+ try {
+ const validToolsSet = new Set(tools);
+ const availableToolsToValidate = availableTools.filter((tool) =>
+ validToolsSet.has(tool.pluginKey),
+ );
+
+ /**
+ * Validates the credentials for a given auth field or set of alternate auth fields for a tool.
+ * If valid admin or user authentication is found, the function returns early. Otherwise, it removes the tool from the set of valid tools.
+ *
+ * @param {string} authField The authentication field or fields (separated by "||" for alternates) to validate.
+ * @param {string} toolName The identifier of the tool being validated.
+ */
+ const validateCredentials = async (authField, toolName) => {
+ const fields = authField.split('||');
+ for (const field of fields) {
+ const adminAuth = process.env[field];
+ if (adminAuth && adminAuth.length > 0) {
+ return;
+ }
+
+ let userAuth = null;
+ try {
+ userAuth = await getUserPluginAuthValue(user, field);
+ } catch (err) {
+ if (field === fields[fields.length - 1] && !userAuth) {
+ throw err;
+ }
+ }
+ if (userAuth && userAuth.length > 0) {
+ return;
+ }
+ }
+
+ validToolsSet.delete(toolName);
+ };
+
+ for (const tool of availableToolsToValidate) {
+ if (!tool.authConfig || tool.authConfig.length === 0) {
+ continue;
+ }
+
+ for (const auth of tool.authConfig) {
+ await validateCredentials(auth.authField, tool.pluginKey);
+ }
+ }
+
+ return Array.from(validToolsSet.values());
+ } catch (err) {
+ logger.error('[validateTools] There was a problem validating tools', err);
+ throw new Error(err);
+ }
+};
+
+/** @typedef {typeof import('@langchain/core/tools').Tool} ToolConstructor */
+/** @typedef {import('@langchain/core/tools').Tool} Tool */
+
+/**
+ * Initializes a tool with authentication values for the given user, supporting alternate authentication fields.
+ * Authentication fields can have alternates separated by "||", and the first defined variable will be used.
+ *
+ * @param {string} userId The user ID for which the tool is being loaded.
+ * @param {Array} authFields Array of strings representing the authentication fields. Supports alternate fields delimited by "||".
+ * @param {ToolConstructor} ToolConstructor The constructor function for the tool to be initialized.
+ * @param {Object} options Optional parameters to be passed to the tool constructor alongside authentication values.
+ * @returns {() => Promise} An Async function that, when called, asynchronously initializes and returns an instance of the tool with authentication.
+ */
+const loadToolWithAuth = (userId, authFields, ToolConstructor, options = {}) => {
+ return async function () {
+ const authValues = await loadAuthValues({ userId, authFields });
+ return new ToolConstructor({ ...options, ...authValues, userId });
+ };
+};
+
+/**
+ * @param {string} toolKey
+ * @returns {Array}
+ */
+const getAuthFields = (toolKey) => {
+ return manifestToolMap[toolKey]?.authConfig.map((auth) => auth.authField) ?? [];
+};
+
+/**
+ *
+ * @param {object} params
+ * @param {string} params.user
+ * @param {Record>} [object.userMCPAuthMap]
+ * @param {AbortSignal} [object.signal]
+ * @param {Pick} [params.agent]
+ * @param {string} [params.model]
+ * @param {EModelEndpoint} [params.endpoint]
+ * @param {LoadToolOptions} [params.options]
+ * @param {boolean} [params.useSpecs]
+ * @param {Array} params.tools
+ * @param {boolean} [params.functions]
+ * @param {boolean} [params.returnMap]
+ * @param {AppConfig['webSearch']} [params.webSearch]
+ * @param {AppConfig['fileStrategy']} [params.fileStrategy]
+ * @param {AppConfig['imageOutputType']} [params.imageOutputType]
+ * @returns {Promise<{ loadedTools: Tool[], toolContextMap: Object } | Record>}
+ */
+const loadTools = async ({
+ user,
+ agent,
+ model,
+ signal,
+ endpoint,
+ userMCPAuthMap,
+ tools = [],
+ options = {},
+ functions = true,
+ returnMap = false,
+ webSearch,
+ fileStrategy,
+ imageOutputType,
+}) => {
+ const toolConstructors = {
+ flux: FluxAPI,
+ calculator: Calculator,
+ google: GoogleSearchAPI,
+ open_weather: OpenWeather,
+ wolfram: StructuredWolfram,
+ 'stable-diffusion': StructuredSD,
+ 'azure-ai-search': StructuredACS,
+ traversaal_search: TraversaalSearch,
+ tavily_search_results_json: TavilySearchResults,
+ };
+
+ const customConstructors = {
+ 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,
+ imageOutputType,
+ fileStrategy,
+ imageFiles,
+ });
+ },
+ };
+
+ const requestedTools = {};
+
+ if (functions === true) {
+ toolConstructors.dalle = DALLE3;
+ }
+
+ /** @type {ImageGenOptions} */
+ const imageGenOptions = {
+ isAgent: !!agent,
+ req: options.req,
+ fileStrategy,
+ processFileURL: options.processFileURL,
+ returnMetadata: options.returnMetadata,
+ uploadImageBuffer: options.uploadImageBuffer,
+ };
+
+ const toolOptions = {
+ flux: imageGenOptions,
+ dalle: imageGenOptions,
+ 'stable-diffusion': imageGenOptions,
+ };
+
+ /** @type {Record} */
+ const toolContextMap = {};
+ const requestedMCPTools = {};
+
+ for (const tool of tools) {
+ if (tool === Tools.execute_code) {
+ requestedTools[tool] = async () => {
+ const authValues = await loadAuthValues({
+ userId: user,
+ authFields: [EnvVar.CODE_API_KEY],
+ });
+ const codeApiKey = authValues[EnvVar.CODE_API_KEY];
+ const { files, toolContext } = await primeCodeFiles(
+ {
+ ...options,
+ agentId: agent?.id,
+ },
+ codeApiKey,
+ );
+ if (toolContext) {
+ toolContextMap[tool] = toolContext;
+ }
+ const CodeExecutionTool = createCodeExecutionTool({
+ user_id: user,
+ files,
+ ...authValues,
+ });
+ CodeExecutionTool.apiKey = codeApiKey;
+ return CodeExecutionTool;
+ };
+ continue;
+ } else if (tool === Tools.file_search) {
+ requestedTools[tool] = async () => {
+ const { files, toolContext } = await primeSearchFiles({
+ ...options,
+ agentId: agent?.id,
+ });
+ if (toolContext) {
+ toolContextMap[tool] = toolContext;
+ }
+
+ /** @type {boolean | undefined} Check if user has FILE_CITATIONS permission */
+ let fileCitations;
+ if (fileCitations == null && options.req?.user != null) {
+ try {
+ fileCitations = await checkAccess({
+ user: options.req.user,
+ permissionType: PermissionTypes.FILE_CITATIONS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+ });
+ } catch (error) {
+ logger.error('[handleTools] FILE_CITATIONS permission check failed:', error);
+ fileCitations = false;
+ }
+ }
+
+ return createFileSearchTool({
+ userId: user,
+ files,
+ entity_id: agent?.id,
+ fileCitations,
+ });
+ };
+ continue;
+ } else if (tool === Tools.web_search) {
+ const result = await loadWebSearchAuth({
+ userId: user,
+ loadAuthValues,
+ webSearchConfig: webSearch,
+ });
+ const { onSearchResults, onGetHighlights } = options?.[Tools.web_search] ?? {};
+ requestedTools[tool] = async () => {
+ toolContextMap[tool] = `# \`${tool}\`:
+Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
+
+**Execute immediately without preface.** After search, provide a brief summary addressing the query directly, then structure your response with clear Markdown formatting (## headers, lists, tables). Cite sources properly, tailor tone to query type, and provide comprehensive details.
+
+**CITATION FORMAT - UNICODE ESCAPE SEQUENCES ONLY:**
+Use these EXACT escape sequences (copy verbatim): \\ue202 (before each anchor), \\ue200 (group start), \\ue201 (group end), \\ue203 (highlight start), \\ue204 (highlight end)
+
+Anchor pattern: \\ue202turn{N}{type}{index} where N=turn number, type=search|news|image|ref, index=0,1,2...
+
+**Examples (copy these exactly):**
+- Single: "Statement.\\ue202turn0search0"
+- Multiple: "Statement.\\ue202turn0search0\\ue202turn0news1"
+- Group: "Statement. \\ue200\\ue202turn0search0\\ue202turn0news1\\ue201"
+- Highlight: "\\ue203Cited text.\\ue204\\ue202turn0search0"
+- Image: "See photo\\ue202turn0image0."
+
+**CRITICAL:** Output escape sequences EXACTLY as shown. Do NOT substitute with † or other symbols. Place anchors AFTER punctuation. Cite every non-obvious fact/quote. NEVER use markdown links, [1], footnotes, or HTML tags.`.trim();
+ return createSearchTool({
+ ...result.authResult,
+ onSearchResults,
+ onGetHighlights,
+ logger,
+ });
+ };
+ continue;
+ } else if (tool && mcpToolPattern.test(tool)) {
+ const [toolName, serverName] = tool.split(Constants.mcp_delimiter);
+ if (toolName === Constants.mcp_server) {
+ /** Placeholder used for UI purposes */
+ continue;
+ }
+ if (
+ serverName &&
+ (await getMCPServersRegistry().getServerConfig(serverName, user)) == undefined
+ ) {
+ logger.warn(
+ `MCP server "${serverName}" for "${toolName}" tool is not configured${agent?.id != null && agent.id ? ` but attached to "${agent.id}"` : ''}`,
+ );
+ continue;
+ }
+ if (toolName === Constants.mcp_all) {
+ requestedMCPTools[serverName] = [
+ {
+ type: 'all',
+ serverName,
+ },
+ ];
+ continue;
+ }
+
+ requestedMCPTools[serverName] = requestedMCPTools[serverName] || [];
+ requestedMCPTools[serverName].push({
+ type: 'single',
+ toolKey: tool,
+ serverName,
+ });
+ continue;
+ }
+
+ if (customConstructors[tool]) {
+ requestedTools[tool] = async () => customConstructors[tool](toolContextMap);
+ continue;
+ }
+
+ if (toolConstructors[tool]) {
+ const options = toolOptions[tool] || {};
+ const toolInstance = loadToolWithAuth(
+ user,
+ getAuthFields(tool),
+ toolConstructors[tool],
+ options,
+ );
+ requestedTools[tool] = toolInstance;
+ continue;
+ }
+ }
+
+ if (returnMap) {
+ return requestedTools;
+ }
+
+ const toolPromises = [];
+ for (const tool of tools) {
+ const validTool = requestedTools[tool];
+ if (validTool) {
+ toolPromises.push(
+ validTool().catch((error) => {
+ logger.error(`Error loading tool ${tool}:`, error);
+ return null;
+ }),
+ );
+ }
+ }
+
+ const loadedTools = (await Promise.all(toolPromises)).flatMap((plugin) => plugin || []);
+ const mcpToolPromises = [];
+ /** MCP server tools are initialized sequentially by server */
+ let index = -1;
+ const failedMCPServers = new Set();
+ const safeUser = createSafeUser(options.req?.user);
+ for (const [serverName, toolConfigs] of Object.entries(requestedMCPTools)) {
+ index++;
+ /** @type {LCAvailableTools} */
+ let availableTools;
+ for (const config of toolConfigs) {
+ try {
+ if (failedMCPServers.has(serverName)) {
+ continue;
+ }
+ const mcpParams = {
+ index,
+ signal,
+ user: safeUser,
+ userMCPAuthMap,
+ res: options.res,
+ model: agent?.model ?? model,
+ serverName: config.serverName,
+ provider: agent?.provider ?? endpoint,
+ };
+
+ if (config.type === 'all' && toolConfigs.length === 1) {
+ /** Handle async loading for single 'all' tool config */
+ mcpToolPromises.push(
+ createMCPTools(mcpParams).catch((error) => {
+ logger.error(`Error loading ${serverName} tools:`, error);
+ return null;
+ }),
+ );
+ continue;
+ }
+ if (!availableTools) {
+ try {
+ availableTools = await getMCPServerTools(safeUser.id, serverName);
+ } catch (error) {
+ logger.error(`Error fetching available tools for MCP server ${serverName}:`, error);
+ }
+ }
+
+ /** Handle synchronous loading */
+ const mcpTool =
+ config.type === 'all'
+ ? await createMCPTools(mcpParams)
+ : await createMCPTool({
+ ...mcpParams,
+ availableTools,
+ toolKey: config.toolKey,
+ });
+
+ if (Array.isArray(mcpTool)) {
+ loadedTools.push(...mcpTool);
+ } else if (mcpTool) {
+ loadedTools.push(mcpTool);
+ } else {
+ failedMCPServers.add(serverName);
+ logger.warn(
+ `MCP tool creation failed for "${config.toolKey}", server may be unavailable or unauthenticated.`,
+ );
+ }
+ } catch (error) {
+ logger.error(`Error loading MCP tool for server ${serverName}:`, error);
+ }
+ }
+ }
+ loadedTools.push(...(await Promise.all(mcpToolPromises)).flatMap((plugin) => plugin || []));
+ return { loadedTools, toolContextMap };
+};
+
+module.exports = {
+ loadToolWithAuth,
+ validateTools,
+ loadTools,
+};
diff --git a/api/app/clients/tools/util/handleTools.test.js b/api/app/clients/tools/util/handleTools.test.js
new file mode 100644
index 0000000000..1adda45c35
--- /dev/null
+++ b/api/app/clients/tools/util/handleTools.test.js
@@ -0,0 +1,286 @@
+const mongoose = require('mongoose');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+
+const mockPluginService = {
+ updateUserPluginAuth: jest.fn(),
+ deleteUserPluginAuth: jest.fn(),
+ getUserPluginAuthValue: jest.fn(),
+};
+
+jest.mock('~/server/services/PluginService', () => mockPluginService);
+
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: jest.fn().mockResolvedValue({
+ // Default app config for tool tests
+ paths: { uploads: '/tmp' },
+ fileStrategy: 'local',
+ filteredTools: [],
+ includedTools: [],
+ }),
+ getCachedTools: jest.fn().mockResolvedValue({
+ // Default cached tools for tests
+ dalle: {
+ type: 'function',
+ function: {
+ name: 'dalle',
+ description: 'DALL-E image generation',
+ parameters: {},
+ },
+ },
+ }),
+}));
+
+const { Calculator } = require('@librechat/agents');
+
+const { User } = require('~/db/models');
+const PluginService = require('~/server/services/PluginService');
+const { validateTools, loadTools, loadToolWithAuth } = require('./handleTools');
+const { StructuredSD, availableTools, DALLE3 } = require('../');
+
+describe('Tool Handlers', () => {
+ let mongoServer;
+ let fakeUser;
+ const pluginKey = 'dalle';
+ const pluginKey2 = 'wolfram';
+ const ToolClass = DALLE3;
+ const initialTools = [pluginKey, pluginKey2];
+ const mockCredential = 'mock-credential';
+ const mainPlugin = availableTools.find((tool) => tool.pluginKey === pluginKey);
+ const authConfigs = mainPlugin.authConfig;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+
+ const userAuthValues = {};
+ mockPluginService.getUserPluginAuthValue.mockImplementation((userId, authField) => {
+ return userAuthValues[`${userId}-${authField}`];
+ });
+ mockPluginService.updateUserPluginAuth.mockImplementation(
+ (userId, authField, _pluginKey, credential) => {
+ const fields = authField.split('||');
+ fields.forEach((field) => {
+ userAuthValues[`${userId}-${field}`] = credential;
+ });
+ },
+ );
+
+ fakeUser = new User({
+ name: 'Fake User',
+ username: 'fakeuser',
+ email: 'fakeuser@example.com',
+ emailVerified: false,
+ // file deepcode ignore NoHardcodedPasswords/test: fake value
+ password: 'fakepassword123',
+ avatar: '',
+ provider: 'local',
+ role: 'USER',
+ googleId: null,
+ plugins: [],
+ refreshToken: [],
+ });
+ await fakeUser.save();
+ for (const authConfig of authConfigs) {
+ await PluginService.updateUserPluginAuth(
+ fakeUser._id,
+ authConfig.authField,
+ pluginKey,
+ mockCredential,
+ );
+ }
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ // Clear mocks but not the database since we need the user to persist
+ jest.clearAllMocks();
+
+ // Reset the mock implementations
+ const userAuthValues = {};
+ mockPluginService.getUserPluginAuthValue.mockImplementation((userId, authField) => {
+ return userAuthValues[`${userId}-${authField}`];
+ });
+ mockPluginService.updateUserPluginAuth.mockImplementation(
+ (userId, authField, _pluginKey, credential) => {
+ const fields = authField.split('||');
+ fields.forEach((field) => {
+ userAuthValues[`${userId}-${field}`] = credential;
+ });
+ },
+ );
+
+ // Re-add the auth configs for the user
+ for (const authConfig of authConfigs) {
+ await PluginService.updateUserPluginAuth(
+ fakeUser._id,
+ authConfig.authField,
+ pluginKey,
+ mockCredential,
+ );
+ }
+ });
+
+ describe('validateTools', () => {
+ it('returns valid tools given input tools and user authentication', async () => {
+ const validTools = await validateTools(fakeUser._id, initialTools);
+ expect(validTools).toBeDefined();
+ expect(validTools.some((tool) => tool === pluginKey)).toBeTruthy();
+ expect(validTools.length).toBeGreaterThan(0);
+ });
+
+ it('removes tools without valid credentials from the validTools array', async () => {
+ const validTools = await validateTools(fakeUser._id, initialTools);
+ expect(validTools.some((tool) => tool.pluginKey === pluginKey2)).toBeFalsy();
+ });
+
+ it('returns an empty array when no authenticated tools are provided', async () => {
+ const validTools = await validateTools(fakeUser._id, []);
+ expect(validTools).toEqual([]);
+ });
+
+ it('should validate a tool from an Environment Variable', async () => {
+ const plugin = availableTools.find((tool) => tool.pluginKey === pluginKey2);
+ const authConfigs = plugin.authConfig;
+ for (const authConfig of authConfigs) {
+ process.env[authConfig.authField] = mockCredential;
+ }
+ const validTools = await validateTools(fakeUser._id, [pluginKey2]);
+ expect(validTools.length).toEqual(1);
+ for (const authConfig of authConfigs) {
+ delete process.env[authConfig.authField];
+ }
+ });
+ });
+
+ describe('loadTools', () => {
+ let toolFunctions;
+ let loadTool1;
+ let loadTool2;
+ let loadTool3;
+ const sampleTools = [...initialTools, 'calculator'];
+ let ToolClass2 = Calculator;
+ let remainingTools = availableTools.filter(
+ (tool) => sampleTools.indexOf(tool.pluginKey) === -1,
+ );
+
+ beforeAll(async () => {
+ const toolMap = await loadTools({
+ user: fakeUser._id,
+ tools: sampleTools,
+ returnMap: true,
+ useSpecs: true,
+ });
+ toolFunctions = toolMap;
+ loadTool1 = toolFunctions[sampleTools[0]];
+ loadTool2 = toolFunctions[sampleTools[1]];
+ loadTool3 = toolFunctions[sampleTools[2]];
+ });
+
+ let originalEnv;
+
+ beforeEach(() => {
+ originalEnv = process.env;
+ process.env = { ...originalEnv };
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ it('returns the expected load functions for requested tools', async () => {
+ expect(loadTool1).toBeDefined();
+ expect(loadTool2).toBeDefined();
+ expect(loadTool3).toBeDefined();
+
+ for (const tool of remainingTools) {
+ expect(toolFunctions[tool.pluginKey]).toBeUndefined();
+ }
+ });
+
+ it('should initialize an authenticated tool or one without authentication', async () => {
+ const authTool = await loadTool1();
+ const tool = await loadTool3();
+ expect(authTool).toBeInstanceOf(ToolClass);
+ expect(tool).toBeInstanceOf(ToolClass2);
+ });
+
+ it('should initialize an authenticated tool with primary auth field', async () => {
+ process.env.DALLE3_API_KEY = 'mocked_api_key';
+ const initToolFunction = loadToolWithAuth(
+ 'userId',
+ ['DALLE3_API_KEY||DALLE_API_KEY'],
+ ToolClass,
+ );
+ const authTool = await initToolFunction();
+
+ expect(authTool).toBeInstanceOf(ToolClass);
+ expect(mockPluginService.getUserPluginAuthValue).not.toHaveBeenCalled();
+ });
+
+ it('should initialize an authenticated tool with alternate auth field when primary is missing', async () => {
+ delete process.env.DALLE3_API_KEY; // Ensure the primary key is not set
+ process.env.DALLE_API_KEY = 'mocked_alternate_api_key';
+ const initToolFunction = loadToolWithAuth(
+ 'userId',
+ ['DALLE3_API_KEY||DALLE_API_KEY'],
+ ToolClass,
+ );
+ const authTool = await initToolFunction();
+
+ expect(authTool).toBeInstanceOf(ToolClass);
+ expect(mockPluginService.getUserPluginAuthValue).toHaveBeenCalledTimes(1);
+ expect(mockPluginService.getUserPluginAuthValue).toHaveBeenCalledWith(
+ 'userId',
+ 'DALLE3_API_KEY',
+ true,
+ );
+ });
+
+ it('should fallback to getUserPluginAuthValue when env vars are missing', async () => {
+ mockPluginService.updateUserPluginAuth('userId', 'DALLE_API_KEY', 'dalle', 'mocked_api_key');
+ const initToolFunction = loadToolWithAuth(
+ 'userId',
+ ['DALLE3_API_KEY||DALLE_API_KEY'],
+ ToolClass,
+ );
+ const authTool = await initToolFunction();
+
+ expect(authTool).toBeInstanceOf(ToolClass);
+ expect(mockPluginService.getUserPluginAuthValue).toHaveBeenCalledTimes(2);
+ });
+
+ it('should throw an error for an unauthenticated tool', async () => {
+ try {
+ await loadTool2();
+ } catch (error) {
+ expect(error).toBeDefined();
+ }
+ });
+ it('returns an empty object when no tools are requested', async () => {
+ toolFunctions = await loadTools({
+ user: fakeUser._id,
+ returnMap: true,
+ useSpecs: true,
+ });
+ expect(toolFunctions).toEqual({});
+ });
+ it('should return the StructuredTool version when using functions', async () => {
+ process.env.SD_WEBUI_URL = mockCredential;
+ toolFunctions = await loadTools({
+ user: fakeUser._id,
+ tools: ['stable-diffusion'],
+ functions: true,
+ returnMap: true,
+ useSpecs: true,
+ });
+ const structuredTool = await toolFunctions['stable-diffusion']();
+ expect(structuredTool).toBeInstanceOf(StructuredSD);
+ delete process.env.SD_WEBUI_URL;
+ });
+ });
+});
diff --git a/api/app/clients/tools/util/index.js b/api/app/clients/tools/util/index.js
new file mode 100644
index 0000000000..9c96fb50f3
--- /dev/null
+++ b/api/app/clients/tools/util/index.js
@@ -0,0 +1,6 @@
+const { validateTools, loadTools } = require('./handleTools');
+
+module.exports = {
+ validateTools,
+ loadTools,
+};
diff --git a/api/app/detectCode.js b/api/app/detectCode.js
deleted file mode 100644
index 5b7d213a67..0000000000
--- a/api/app/detectCode.js
+++ /dev/null
@@ -1,54 +0,0 @@
-const { ModelOperations } = require('@vscode/vscode-languagedetection');
-const codeRegex = /(```[\s\S]*?```)/g;
-const languageMatch = /```(\w+)/;
-
-const detectCode = async (text) => {
- try {
- if (!text.match(codeRegex)) {
- // console.log('disqualified for non-code match')
- return text;
- }
-
- if (text.match(languageMatch)) {
- // console.log('disqualified for language match')
- return text;
- }
-
- // console.log('qualified for code match');
- const modelOperations = new ModelOperations();
- const regexSplit = (await import('./regexSplit.mjs')).default;
- const parts = regexSplit(text, codeRegex);
-
- const output = parts.map(async (part) => {
- if (part.match(codeRegex)) {
- const code = part.slice(3, -3);
- const language = await modelOperations.runModel(code);
- return part.replace(/^```/, `\`\`\`${language[0].languageId}`);
- } else {
- // return i > 0 ? '\n' + part : part;
- return part;
- }
- });
-
- return (await Promise.all(output)).join('');
- } catch (e) {
- console.log('Error in detectCode function\n', e);
- return text;
- }
-};
-
-// const example3 = {
-// text: "By default, the function generates an 8-character password with uppercase and lowercase letters and digits, but no special characters.\n\nTo use this function, simply call it with the desired arguments. For example:\n\n```\n>>> generate_password()\n'wE5pUxV7'\n>>> generate_password(length=12, special_chars=True)\n'M4v&^gJ*8#qH'\n>>> generate_password(uppercase=False, digits=False)\n'zajyprxr'\n``` \n\nNote that the randomness is used to select characters from the available character sets, but the resulting password is always deterministic given the same inputs. This makes the function useful for generating secure passwords that meet specific requirements."
-// };
-
-// const example4 = {
-// text: 'here\'s a cool function:\n```\nimport random\nimport string\n\ndef generate_password(length=8, uppercase=True, lowercase=True, digits=True, special_chars=False):\n """Generate a random password with specified requirements.\n\n Args:\n length (int): The length of the password. Default is 8.\n uppercase (bool): Whether to include uppercase letters. Default is True.\n lowercase (bool): Whether to include lowercase letters. Default is True.\n digits (bool): Whether to include digits. Default is True.\n special_chars (bool): Whether to include special characters. Default is False.\n\n Returns:\n str: A random password with the specified requirements.\n """\n # Define character sets to use in password generation\n chars = ""\n if uppercase:\n chars += string.ascii_uppercase\n if lowercase:\n chars += string.ascii_lowercase\n if digits:\n chars += string.digits\n if special_chars:\n chars += string.punctuation\n\n # Generate the password\n password = "".join(random.choice(chars) for _ in range(length))\n return password\n```\n\nThis function takes several arguments'
-// };
-
-// write an immediately invoked function to test this
-// (async () => {
-// const result = await detectCode(example3.text);
-// console.log(result);
-// })();
-
-module.exports = detectCode;
diff --git a/api/app/getCitations.js b/api/app/getCitations.js
deleted file mode 100644
index f4086a4e9e..0000000000
--- a/api/app/getCitations.js
+++ /dev/null
@@ -1,13 +0,0 @@
-// const regex = / \[\d+\..*?\]\(.*?\)/g;
-const regex = / \[.*?]\(.*?\)/g;
-
-const getCitations = (res) => {
- const textBlocks = res.details.adaptiveCards[0].body;
- if (!textBlocks) return '';
- let links = textBlocks[textBlocks.length - 1]?.text.match(regex);
- if (links?.length === 0 || !links) return '';
- links = links.map((link) => link.trim());
- return links.join('\n');
-};
-
-module.exports = getCitations;
\ No newline at end of file
diff --git a/api/app/index.js b/api/app/index.js
index 7b61f8de95..bb11fb0877 100644
--- a/api/app/index.js
+++ b/api/app/index.js
@@ -1,21 +1,5 @@
-const { askClient } = require('./chatgpt-client');
-const { browserClient } = require('./chatgpt-browser');
-const customClient = require('./chatgpt-custom');
-const { askBing } = require('./bingai');
-const { askSydney } = require('./sydney');
-const titleConvo = require('./titleConvo');
-const getCitations = require('./getCitations');
-const citeText = require('./citeText');
-const detectCode = require('./detectCode');
+const clients = require('./clients');
module.exports = {
- askClient,
- browserClient,
- customClient,
- askBing,
- askSydney,
- titleConvo,
- getCitations,
- citeText,
- detectCode
-};
\ No newline at end of file
+ ...clients,
+};
diff --git a/api/app/regexSplit.mjs b/api/app/regexSplit.mjs
deleted file mode 100644
index 802ec343b7..0000000000
--- a/api/app/regexSplit.mjs
+++ /dev/null
@@ -1,46 +0,0 @@
-const primaryRegex = /```([^`\n]*?)\n([\s\S]*?)\n```/g;
-const secondaryRegex = /```([^`\n]*?)\n?([\s\S]*?)\n?```/g;
-
-const unenclosedCodeTest = (text) => {
- let workingText = text;
- // if (workingText.startsWith('<') || (!workingText.startsWith('`') && workingText.match(/```/g)?.length === 1)) {
- // workingText = `\`\`\`${workingText}`
- // }
-
- return workingText.trim();
-};
-
-export default function regexSplit(string) {
- let matches = [...string.matchAll(primaryRegex)];
-
- if (!matches[0]) {
- matches = [...string.matchAll(secondaryRegex)];
- }
-
- const output = [matches[0].input.slice(0, matches[0].index)];
-
- // console.log(matches);
-
- for (let i = 0; i < matches.length; i++) {
- const [fullMatch, language, code] = matches[i];
- // const formattedCode = code.replace(/`+/g, '\\`');
- output.push(`\`\`\`${language}\n${code}\n\`\`\``);
- if (i < matches.length - 1) {
- let nextText = string.slice(matches[i].index + fullMatch.length, matches[i + 1].index);
- nextText = unenclosedCodeTest(nextText);
- output.push(nextText);
- } else {
- const lastMatch = matches[matches.length - 1][0];
- // console.log(lastMatch);
- // console.log(matches[0].input.split(lastMatch));
- let rest = matches[0].input.split(lastMatch)[1]
-
- if (rest) {
- rest = unenclosedCodeTest(rest);
- output.push(rest);
- }
- }
- }
-
- return output;
-}
diff --git a/api/app/sydney.js b/api/app/sydney.js
deleted file mode 100644
index fe47c74f57..0000000000
--- a/api/app/sydney.js
+++ /dev/null
@@ -1,36 +0,0 @@
-require('dotenv').config();
-const { KeyvFile } = require('keyv-file');
-
-const askSydney = async ({ text, progressCallback, convo }) => {
- const { BingAIClient } = (await import('@waylaidwanderer/chatgpt-api'));
-
- const sydneyClient = new BingAIClient({
- // "_U" cookie from bing.com
- userToken: process.env.BING_TOKEN,
- // If the above doesn't work, provide all your cookies as a string instead
- // cookies: '',
- debug: false,
- cache: { store: new KeyvFile({ filename: './data/cache.json' }) }
- });
-
- let options = {
- jailbreakConversationId: true,
- onProgress: async (partialRes) => await progressCallback(partialRes),
- };
-
- if (convo.parentMessageId) {
- options = { ...options, jailbreakConversationId: convo.jailbreakConversationId, parentMessageId: convo.parentMessageId };
- }
-
- console.log('sydney options', options);
-
- const res = await sydneyClient.sendMessage(text, options
- );
-
- return res;
-
- // for reference:
- // https://github.com/waylaidwanderer/node-chatgpt-api/blob/main/demos/use-bing-client.js
-};
-
-module.exports = { askSydney };
diff --git a/api/app/titleConvo.js b/api/app/titleConvo.js
deleted file mode 100644
index f793075d98..0000000000
--- a/api/app/titleConvo.js
+++ /dev/null
@@ -1,24 +0,0 @@
-const { Configuration, OpenAIApi } = require('openai');
-
-const titleConvo = async ({ message, response, model }) => {
- const configuration = new Configuration({
- apiKey: process.env.OPENAI_KEY
- });
- const openai = new OpenAIApi(configuration);
- const completion = await openai.createChatCompletion({
- model: 'gpt-3.5-turbo',
- messages: [
- {
- role: 'system',
- content:
- 'You are a title-generator with one job: titling the conversation provided by a user in title case.'
- },
- { role: 'user', content: `In 5 words or less, summarize the conversation below with a title in title case. Don't refer to the participants of the conversation by name. Do not include punctuation or quotation marks. Your response should be in title case, exclusively containing the title. Conversation:\n\nUser: "${message}"\n\n${model}: "${response}"\n\nTitle: ` },
- ]
- });
-
- //eslint-disable-next-line
- return completion.data.choices[0].message.content.replace(/["\.]/g, '');
-};
-
-module.exports = titleConvo;
diff --git a/api/cache/banViolation.js b/api/cache/banViolation.js
new file mode 100644
index 0000000000..3a2d9791b4
--- /dev/null
+++ b/api/cache/banViolation.js
@@ -0,0 +1,77 @@
+const { logger } = require('@librechat/data-schemas');
+const { isEnabled, math } = require('@librechat/api');
+const { ViolationTypes } = require('librechat-data-provider');
+const { deleteAllUserSessions } = require('~/models');
+const { removePorts } = require('~/server/utils');
+const getLogStores = require('./getLogStores');
+
+const { BAN_VIOLATIONS, BAN_INTERVAL } = process.env ?? {};
+const interval = math(BAN_INTERVAL, 20);
+
+/**
+ * Bans a user based on violation criteria.
+ *
+ * If the user's violation count is a multiple of the BAN_INTERVAL, the user will be banned.
+ * The duration of the ban is determined by the BAN_DURATION environment variable.
+ * If BAN_DURATION is not set or invalid, the user will not be banned.
+ * Sessions will be deleted and the refreshToken cookie will be cleared even with
+ * an invalid or nill duration, which is a "soft" ban; the user can remain active until
+ * access token expiry.
+ *
+ * @async
+ * @param {Object} req - Express request object containing user information.
+ * @param {Object} res - Express response object.
+ * @param {Object} errorMessage - Object containing user violation details.
+ * @param {string} errorMessage.type - Type of the violation.
+ * @param {string} errorMessage.user_id - ID of the user who committed the violation.
+ * @param {number} errorMessage.violation_count - Number of violations committed by the user.
+ *
+ * @returns {Promise}
+ *
+ */
+const banViolation = async (req, res, errorMessage) => {
+ if (!isEnabled(BAN_VIOLATIONS)) {
+ return;
+ }
+ if (!errorMessage) {
+ return;
+ }
+
+ const { type, user_id, prev_count, violation_count } = errorMessage;
+
+ const prevThreshold = Math.floor(prev_count / interval);
+ const currentThreshold = Math.floor(violation_count / interval);
+
+ if (prevThreshold >= currentThreshold) {
+ return;
+ }
+
+ await deleteAllUserSessions({ userId: user_id });
+ res.clearCookie('refreshToken');
+
+ const banLogs = getLogStores(ViolationTypes.BAN);
+ const duration = errorMessage.duration || banLogs.opts.ttl;
+ if (duration <= 0) {
+ return;
+ }
+
+ req.ip = removePorts(req);
+ logger.info(
+ `[BAN] Banning user ${user_id} ${req.ip ? `@ ${req.ip} ` : ''}for ${
+ duration / 1000 / 60
+ } minutes`,
+ );
+
+ const expiresAt = Date.now() + duration;
+ await banLogs.set(user_id, { type, violation_count, duration, expiresAt });
+ if (req.ip) {
+ await banLogs.set(req.ip, { type, user_id, violation_count, duration, expiresAt });
+ }
+
+ errorMessage.ban = true;
+ errorMessage.ban_duration = duration;
+
+ return;
+};
+
+module.exports = banViolation;
diff --git a/api/cache/banViolation.spec.js b/api/cache/banViolation.spec.js
new file mode 100644
index 0000000000..df98753498
--- /dev/null
+++ b/api/cache/banViolation.spec.js
@@ -0,0 +1,136 @@
+const mongoose = require('mongoose');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const banViolation = require('./banViolation');
+
+// Mock deleteAllUserSessions since we're testing ban logic, not session deletion
+jest.mock('~/models', () => ({
+ ...jest.requireActual('~/models'),
+ deleteAllUserSessions: jest.fn().mockResolvedValue(true),
+}));
+
+describe('banViolation', () => {
+ let mongoServer;
+ let req, res, errorMessage;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(() => {
+ req = {
+ ip: '127.0.0.1',
+ cookies: {
+ refreshToken: 'someToken',
+ },
+ };
+ res = {
+ clearCookie: jest.fn(),
+ };
+ errorMessage = {
+ type: 'someViolation',
+ user_id: new mongoose.Types.ObjectId().toString(), // Use valid ObjectId
+ prev_count: 0,
+ violation_count: 0,
+ };
+ process.env.BAN_VIOLATIONS = 'true';
+ process.env.BAN_DURATION = '7200000'; // 2 hours in ms
+ process.env.BAN_INTERVAL = '20';
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should not ban if BAN_VIOLATIONS are not enabled', async () => {
+ process.env.BAN_VIOLATIONS = 'false';
+ await banViolation(req, res, errorMessage);
+ expect(errorMessage.ban).toBeFalsy();
+ });
+
+ it('should not ban if errorMessage is not provided', async () => {
+ await banViolation(req, res, null);
+ expect(errorMessage.ban).toBeFalsy();
+ });
+
+ it('[1/3] should ban if violation_count crosses the interval threshold: 19 -> 39', async () => {
+ errorMessage.prev_count = 19;
+ errorMessage.violation_count = 39;
+ await banViolation(req, res, errorMessage);
+ expect(errorMessage.ban).toBeTruthy();
+ });
+
+ it('[2/3] should ban if violation_count crosses the interval threshold: 19 -> 20', async () => {
+ errorMessage.prev_count = 19;
+ errorMessage.violation_count = 20;
+ await banViolation(req, res, errorMessage);
+ expect(errorMessage.ban).toBeTruthy();
+ });
+
+ const randomValueAbove = Math.floor(20 + Math.random() * 100);
+ it(`[3/3] should ban if violation_count crosses the interval threshold: 19 -> ${randomValueAbove}`, async () => {
+ errorMessage.prev_count = 19;
+ errorMessage.violation_count = randomValueAbove;
+ await banViolation(req, res, errorMessage);
+ expect(errorMessage.ban).toBeTruthy();
+ });
+
+ it('should handle invalid BAN_INTERVAL and default to 20', async () => {
+ process.env.BAN_INTERVAL = 'invalid';
+ errorMessage.prev_count = 19;
+ errorMessage.violation_count = 39;
+ await banViolation(req, res, errorMessage);
+ expect(errorMessage.ban).toBeTruthy();
+ });
+
+ it('should ban if BAN_DURATION is invalid as default is 2 hours', async () => {
+ process.env.BAN_DURATION = 'invalid';
+ errorMessage.prev_count = 19;
+ errorMessage.violation_count = 39;
+ await banViolation(req, res, errorMessage);
+ expect(errorMessage.ban).toBeTruthy();
+ });
+
+ it('should not ban if BAN_DURATION is 0 but should clear cookies', async () => {
+ process.env.BAN_DURATION = '0';
+ errorMessage.prev_count = 19;
+ errorMessage.violation_count = 39;
+ await banViolation(req, res, errorMessage);
+ expect(res.clearCookie).toHaveBeenCalledWith('refreshToken');
+ });
+
+ it('should not ban if violation_count does not change', async () => {
+ errorMessage.prev_count = 0;
+ errorMessage.violation_count = 0;
+ await banViolation(req, res, errorMessage);
+ expect(errorMessage.ban).toBeFalsy();
+ });
+
+ it('[1/2] should not ban if violation_count does not cross the interval threshold: 0 -> 19', async () => {
+ errorMessage.prev_count = 0;
+ errorMessage.violation_count = 19;
+ await banViolation(req, res, errorMessage);
+ expect(errorMessage.ban).toBeFalsy();
+ });
+
+ const randomValueUnder = Math.floor(1 + Math.random() * 19);
+ it(`[2/2] should not ban if violation_count does not cross the interval threshold: 0 -> ${randomValueUnder}`, async () => {
+ errorMessage.prev_count = 0;
+ errorMessage.violation_count = randomValueUnder;
+ await banViolation(req, res, errorMessage);
+ expect(errorMessage.ban).toBeFalsy();
+ });
+
+ it('[EDGE CASE] should not ban if violation_count is lower', async () => {
+ errorMessage.prev_count = 0;
+ errorMessage.violation_count = -10;
+ await banViolation(req, res, errorMessage);
+ expect(errorMessage.ban).toBeFalsy();
+ });
+});
diff --git a/api/cache/clearPendingReq.js b/api/cache/clearPendingReq.js
new file mode 100644
index 0000000000..b4329b1f06
--- /dev/null
+++ b/api/cache/clearPendingReq.js
@@ -0,0 +1,49 @@
+const { isEnabled } = require('@librechat/api');
+const { Time, CacheKeys } = require('librechat-data-provider');
+const getLogStores = require('./getLogStores');
+
+const { USE_REDIS, LIMIT_CONCURRENT_MESSAGES } = process.env ?? {};
+
+/**
+ * Clear or decrement pending requests from the cache.
+ * Checks the environmental variable LIMIT_CONCURRENT_MESSAGES;
+ * if the rule is enabled ('true'), it either decrements the count of pending requests
+ * or deletes the key if the count is less than or equal to 1.
+ *
+ * @module clearPendingReq
+ * @requires ./getLogStores
+ * @requires ../server/utils
+ * @requires process
+ *
+ * @async
+ * @function
+ * @param {Object} params - The parameters object.
+ * @param {string} params.userId - The user ID for which the pending requests are to be cleared or decremented.
+ * @param {Object} [params.cache] - An optional cache object to use. If not provided, a default cache will be fetched using getLogStores.
+ * @returns {Promise} A promise that either decrements the 'pendingRequests' count, deletes the key from the store, or resolves with no value.
+ */
+const clearPendingReq = async ({ userId, cache: _cache }) => {
+ if (!userId) {
+ return;
+ } else if (!isEnabled(LIMIT_CONCURRENT_MESSAGES)) {
+ return;
+ }
+
+ const namespace = CacheKeys.PENDING_REQ;
+ const cache = _cache ?? getLogStores(namespace);
+
+ if (!cache) {
+ return;
+ }
+
+ const key = `${isEnabled(USE_REDIS) ? namespace : ''}:${userId ?? ''}`;
+ const currentReq = +((await cache.get(key)) ?? 0);
+
+ if (currentReq && currentReq >= 1) {
+ await cache.set(key, currentReq - 1, Time.ONE_MINUTE);
+ } else {
+ await cache.delete(key);
+ }
+};
+
+module.exports = clearPendingReq;
diff --git a/api/cache/getLogStores.js b/api/cache/getLogStores.js
new file mode 100644
index 0000000000..40aac08ee6
--- /dev/null
+++ b/api/cache/getLogStores.js
@@ -0,0 +1,227 @@
+const { Keyv } = require('keyv');
+const { Time, CacheKeys, ViolationTypes } = require('librechat-data-provider');
+const {
+ logFile,
+ keyvMongo,
+ cacheConfig,
+ sessionCache,
+ standardCache,
+ violationCache,
+} = require('@librechat/api');
+
+const namespaces = {
+ [ViolationTypes.GENERAL]: new Keyv({ store: logFile, namespace: 'violations' }),
+ [ViolationTypes.LOGINS]: violationCache(ViolationTypes.LOGINS),
+ [ViolationTypes.CONCURRENT]: violationCache(ViolationTypes.CONCURRENT),
+ [ViolationTypes.NON_BROWSER]: violationCache(ViolationTypes.NON_BROWSER),
+ [ViolationTypes.MESSAGE_LIMIT]: violationCache(ViolationTypes.MESSAGE_LIMIT),
+ [ViolationTypes.REGISTRATIONS]: violationCache(ViolationTypes.REGISTRATIONS),
+ [ViolationTypes.TOKEN_BALANCE]: violationCache(ViolationTypes.TOKEN_BALANCE),
+ [ViolationTypes.TTS_LIMIT]: violationCache(ViolationTypes.TTS_LIMIT),
+ [ViolationTypes.STT_LIMIT]: violationCache(ViolationTypes.STT_LIMIT),
+ [ViolationTypes.CONVO_ACCESS]: violationCache(ViolationTypes.CONVO_ACCESS),
+ [ViolationTypes.TOOL_CALL_LIMIT]: violationCache(ViolationTypes.TOOL_CALL_LIMIT),
+ [ViolationTypes.FILE_UPLOAD_LIMIT]: violationCache(ViolationTypes.FILE_UPLOAD_LIMIT),
+ [ViolationTypes.VERIFY_EMAIL_LIMIT]: violationCache(ViolationTypes.VERIFY_EMAIL_LIMIT),
+ [ViolationTypes.RESET_PASSWORD_LIMIT]: violationCache(ViolationTypes.RESET_PASSWORD_LIMIT),
+ [ViolationTypes.ILLEGAL_MODEL_REQUEST]: violationCache(ViolationTypes.ILLEGAL_MODEL_REQUEST),
+ [ViolationTypes.BAN]: new Keyv({
+ store: keyvMongo,
+ namespace: CacheKeys.BANS,
+ ttl: cacheConfig.BAN_DURATION,
+ }),
+
+ [CacheKeys.OPENID_SESSION]: sessionCache(CacheKeys.OPENID_SESSION),
+ [CacheKeys.SAML_SESSION]: sessionCache(CacheKeys.SAML_SESSION),
+
+ [CacheKeys.ROLES]: standardCache(CacheKeys.ROLES),
+ [CacheKeys.APP_CONFIG]: standardCache(CacheKeys.APP_CONFIG),
+ [CacheKeys.CONFIG_STORE]: standardCache(CacheKeys.CONFIG_STORE),
+ [CacheKeys.PENDING_REQ]: standardCache(CacheKeys.PENDING_REQ),
+ [CacheKeys.ENCODED_DOMAINS]: new Keyv({ store: keyvMongo, namespace: CacheKeys.ENCODED_DOMAINS }),
+ [CacheKeys.ABORT_KEYS]: standardCache(CacheKeys.ABORT_KEYS, Time.TEN_MINUTES),
+ [CacheKeys.TOKEN_CONFIG]: standardCache(CacheKeys.TOKEN_CONFIG, Time.THIRTY_MINUTES),
+ [CacheKeys.GEN_TITLE]: standardCache(CacheKeys.GEN_TITLE, Time.TWO_MINUTES),
+ [CacheKeys.S3_EXPIRY_INTERVAL]: standardCache(CacheKeys.S3_EXPIRY_INTERVAL, Time.THIRTY_MINUTES),
+ [CacheKeys.MODEL_QUERIES]: standardCache(CacheKeys.MODEL_QUERIES),
+ [CacheKeys.AUDIO_RUNS]: standardCache(CacheKeys.AUDIO_RUNS, Time.TEN_MINUTES),
+ [CacheKeys.MESSAGES]: standardCache(CacheKeys.MESSAGES, Time.ONE_MINUTE),
+ [CacheKeys.FLOWS]: standardCache(CacheKeys.FLOWS, Time.ONE_MINUTE * 3),
+ [CacheKeys.OPENID_EXCHANGED_TOKENS]: standardCache(
+ CacheKeys.OPENID_EXCHANGED_TOKENS,
+ Time.TEN_MINUTES,
+ ),
+};
+
+/**
+ * Gets all cache stores that have TTL configured
+ * @returns {Keyv[]}
+ */
+function getTTLStores() {
+ return Object.values(namespaces).filter(
+ (store) =>
+ store instanceof Keyv &&
+ parseInt(store.opts?.ttl ?? '0') > 0 &&
+ !store.opts?.store?.constructor?.name?.includes('Redis'), // Only include non-Redis stores
+ );
+}
+
+/**
+ * Clears entries older than the cache's TTL
+ * @param {Keyv} cache
+ */
+async function clearExpiredFromCache(cache) {
+ if (!cache?.opts?.store?.entries) {
+ return;
+ }
+
+ const ttl = cache.opts.ttl;
+ if (!ttl) {
+ return;
+ }
+
+ const expiryTime = Date.now() - ttl;
+ let cleared = 0;
+
+ // Get all keys first to avoid modification during iteration
+ const keys = Array.from(cache.opts.store.keys());
+
+ for (const key of keys) {
+ try {
+ const raw = cache.opts.store.get(key);
+ if (!raw) {
+ continue;
+ }
+
+ const data = cache.opts.deserialize(raw);
+ // Check if the entry is older than TTL
+ if (data?.expires && data.expires <= expiryTime) {
+ const deleted = await cache.opts.store.delete(key);
+ if (!deleted) {
+ cacheConfig.DEBUG_MEMORY_CACHE &&
+ console.warn(`[Cache] Error deleting entry: ${key} from ${cache.opts.namespace}`);
+ continue;
+ }
+ cleared++;
+ }
+ } catch (error) {
+ cacheConfig.DEBUG_MEMORY_CACHE &&
+ console.log(`[Cache] Error processing entry from ${cache.opts.namespace}:`, error);
+ const deleted = await cache.opts.store.delete(key);
+ if (!deleted) {
+ cacheConfig.DEBUG_MEMORY_CACHE &&
+ console.warn(`[Cache] Error deleting entry: ${key} from ${cache.opts.namespace}`);
+ continue;
+ }
+ cleared++;
+ }
+ }
+
+ if (cleared > 0) {
+ cacheConfig.DEBUG_MEMORY_CACHE &&
+ console.log(
+ `[Cache] Cleared ${cleared} entries older than ${ttl}ms from ${cache.opts.namespace}`,
+ );
+ }
+}
+
+const auditCache = () => {
+ const ttlStores = getTTLStores();
+ console.log('[Cache] Starting audit');
+
+ ttlStores.forEach((store) => {
+ if (!store?.opts?.store?.entries) {
+ return;
+ }
+
+ console.log(`[Cache] ${store.opts.namespace} entries:`, {
+ count: store.opts.store.size,
+ ttl: store.opts.ttl,
+ keys: Array.from(store.opts.store.keys()),
+ entriesWithTimestamps: Array.from(store.opts.store.entries()).map(([key, value]) => ({
+ key,
+ value,
+ })),
+ });
+ });
+};
+
+/**
+ * Clears expired entries from all TTL-enabled stores
+ */
+async function clearAllExpiredFromCache() {
+ const ttlStores = getTTLStores();
+ await Promise.all(ttlStores.map((store) => clearExpiredFromCache(store)));
+
+ // Force garbage collection if available (Node.js with --expose-gc flag)
+ if (global.gc) {
+ global.gc();
+ }
+}
+
+if (!cacheConfig.USE_REDIS && !cacheConfig.CI) {
+ /** @type {Set} */
+ const cleanupIntervals = new Set();
+
+ // Clear expired entries every 30 seconds
+ const cleanup = setInterval(() => {
+ clearAllExpiredFromCache();
+ }, Time.THIRTY_SECONDS);
+
+ cleanupIntervals.add(cleanup);
+
+ if (cacheConfig.DEBUG_MEMORY_CACHE) {
+ const monitor = setInterval(() => {
+ const ttlStores = getTTLStores();
+ const memory = process.memoryUsage();
+ const totalSize = ttlStores.reduce((sum, store) => sum + (store.opts?.store?.size ?? 0), 0);
+
+ console.log('[Cache] Memory usage:', {
+ heapUsed: `${(memory.heapUsed / 1024 / 1024).toFixed(2)} MB`,
+ heapTotal: `${(memory.heapTotal / 1024 / 1024).toFixed(2)} MB`,
+ rss: `${(memory.rss / 1024 / 1024).toFixed(2)} MB`,
+ external: `${(memory.external / 1024 / 1024).toFixed(2)} MB`,
+ totalCacheEntries: totalSize,
+ });
+
+ auditCache();
+ }, Time.ONE_MINUTE);
+
+ cleanupIntervals.add(monitor);
+ }
+
+ const dispose = () => {
+ cacheConfig.DEBUG_MEMORY_CACHE && console.log('[Cache] Cleaning up and shutting down...');
+ cleanupIntervals.forEach((interval) => clearInterval(interval));
+ cleanupIntervals.clear();
+
+ // One final cleanup before exit
+ clearAllExpiredFromCache().then(() => {
+ cacheConfig.DEBUG_MEMORY_CACHE && console.log('[Cache] Final cleanup completed');
+ process.exit(0);
+ });
+ };
+
+ // Handle various termination signals
+ process.on('SIGTERM', dispose);
+ process.on('SIGINT', dispose);
+ process.on('SIGQUIT', dispose);
+ process.on('SIGHUP', dispose);
+}
+
+/**
+ * Returns the keyv cache specified by type.
+ * If an invalid type is passed, an error will be thrown.
+ *
+ * @param {string} key - The key for the namespace to access
+ * @returns {Keyv} - If a valid key is passed, returns an object containing the cache store of the specified key.
+ * @throws Will throw an error if an invalid key is passed.
+ */
+const getLogStores = (key) => {
+ if (!key || !namespaces[key]) {
+ throw new Error(`Invalid store key: ${key}`);
+ }
+ return namespaces[key];
+};
+
+module.exports = getLogStores;
diff --git a/api/cache/index.js b/api/cache/index.js
new file mode 100644
index 0000000000..1bad242f13
--- /dev/null
+++ b/api/cache/index.js
@@ -0,0 +1,4 @@
+const getLogStores = require('./getLogStores');
+const logViolation = require('./logViolation');
+
+module.exports = { getLogStores, logViolation };
diff --git a/api/cache/logViolation.js b/api/cache/logViolation.js
new file mode 100644
index 0000000000..1ff65c6ccd
--- /dev/null
+++ b/api/cache/logViolation.js
@@ -0,0 +1,40 @@
+const { isEnabled } = require('@librechat/api');
+const { ViolationTypes } = require('librechat-data-provider');
+const getLogStores = require('./getLogStores');
+const banViolation = require('./banViolation');
+
+/**
+ * Logs the violation.
+ *
+ * @param {Object} req - Express request object containing user information.
+ * @param {Object} res - Express response object.
+ * @param {string} type - The type of violation.
+ * @param {Object} errorMessage - The error message to log.
+ * @param {number | string} [score=1] - The severity of the violation. Defaults to 1
+ */
+const logViolation = async (req, res, type, errorMessage, score = 1) => {
+ const userId = req.user?.id ?? req.user?._id;
+ if (!userId) {
+ return;
+ }
+ const logs = getLogStores(ViolationTypes.GENERAL);
+ const violationLogs = getLogStores(type);
+ const key = isEnabled(process.env.USE_REDIS) ? `${type}:${userId}` : userId;
+
+ const userViolations = (await violationLogs.get(key)) ?? 0;
+ const violationCount = +userViolations + +score;
+ await violationLogs.set(key, violationCount);
+
+ errorMessage.user_id = userId;
+ errorMessage.prev_count = userViolations;
+ errorMessage.violation_count = violationCount;
+ errorMessage.date = new Date().toISOString();
+
+ await banViolation(req, res, errorMessage);
+ const userLogs = (await logs.get(key)) ?? [];
+ userLogs.push(errorMessage);
+ delete errorMessage.user_id;
+ await logs.set(key, userLogs);
+};
+
+module.exports = logViolation;
diff --git a/api/config/index.js b/api/config/index.js
new file mode 100644
index 0000000000..3b6d869332
--- /dev/null
+++ b/api/config/index.js
@@ -0,0 +1,38 @@
+const { EventSource } = require('eventsource');
+const { Time } = require('librechat-data-provider');
+const {
+ MCPManager,
+ FlowStateManager,
+ MCPServersRegistry,
+ OAuthReconnectionManager,
+} = require('@librechat/api');
+const logger = require('./winston');
+
+global.EventSource = EventSource;
+
+/** @type {MCPManager} */
+let flowManager = null;
+
+/**
+ * @param {Keyv} flowsCache
+ * @returns {FlowStateManager}
+ */
+function getFlowStateManager(flowsCache) {
+ if (!flowManager) {
+ flowManager = new FlowStateManager(flowsCache, {
+ ttl: Time.ONE_MINUTE * 3,
+ });
+ }
+ return flowManager;
+}
+
+module.exports = {
+ logger,
+ createMCPServersRegistry: MCPServersRegistry.createInstance,
+ getMCPServersRegistry: MCPServersRegistry.getInstance,
+ createMCPManager: MCPManager.createInstance,
+ getMCPManager: MCPManager.getInstance,
+ getFlowStateManager,
+ createOAuthReconnectionManager: OAuthReconnectionManager.createInstance,
+ getOAuthReconnectionManager: OAuthReconnectionManager.getInstance,
+};
diff --git a/api/config/meiliLogger.js b/api/config/meiliLogger.js
new file mode 100644
index 0000000000..c5e60ea157
--- /dev/null
+++ b/api/config/meiliLogger.js
@@ -0,0 +1,75 @@
+const path = require('path');
+const winston = require('winston');
+require('winston-daily-rotate-file');
+
+const logDir = path.join(__dirname, '..', 'logs');
+
+const { NODE_ENV, DEBUG_LOGGING = false } = process.env;
+
+const useDebugLogging =
+ (typeof DEBUG_LOGGING === 'string' && DEBUG_LOGGING?.toLowerCase() === 'true') ||
+ DEBUG_LOGGING === true;
+
+const levels = {
+ error: 0,
+ warn: 1,
+ info: 2,
+ http: 3,
+ verbose: 4,
+ debug: 5,
+ activity: 6,
+ silly: 7,
+};
+
+winston.addColors({
+ info: 'green', // fontStyle color
+ warn: 'italic yellow',
+ error: 'red',
+ debug: 'blue',
+});
+
+const level = () => {
+ const env = NODE_ENV || 'development';
+ const isDevelopment = env === 'development';
+ return isDevelopment ? 'debug' : 'warn';
+};
+
+const fileFormat = winston.format.combine(
+ winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
+ winston.format.errors({ stack: true }),
+ winston.format.splat(),
+);
+
+const logLevel = useDebugLogging ? 'debug' : 'error';
+const transports = [
+ new winston.transports.DailyRotateFile({
+ level: logLevel,
+ filename: `${logDir}/meiliSync-%DATE%.log`,
+ datePattern: 'YYYY-MM-DD',
+ zippedArchive: true,
+ maxSize: '20m',
+ maxFiles: '14d',
+ format: fileFormat,
+ }),
+];
+
+const consoleFormat = winston.format.combine(
+ winston.format.colorize({ all: true }),
+ winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
+ winston.format.printf((info) => `${info.timestamp} ${info.level}: ${info.message}`),
+);
+
+transports.push(
+ new winston.transports.Console({
+ level: 'info',
+ format: consoleFormat,
+ }),
+);
+
+const logger = winston.createLogger({
+ level: level(),
+ levels,
+ transports,
+});
+
+module.exports = logger;
diff --git a/api/config/parsers.js b/api/config/parsers.js
new file mode 100644
index 0000000000..46394a9ff8
--- /dev/null
+++ b/api/config/parsers.js
@@ -0,0 +1,232 @@
+const { klona } = require('klona');
+const winston = require('winston');
+const traverse = require('traverse');
+
+const SPLAT_SYMBOL = Symbol.for('splat');
+const MESSAGE_SYMBOL = Symbol.for('message');
+const CONSOLE_JSON_STRING_LENGTH = parseInt(process.env.CONSOLE_JSON_STRING_LENGTH) || 255;
+const DEBUG_MESSAGE_LENGTH = parseInt(process.env.DEBUG_MESSAGE_LENGTH) || 150;
+
+const sensitiveKeys = [
+ /^(sk-)[^\s]+/, // OpenAI API key pattern
+ /(Bearer )[^\s]+/, // Header: Bearer token pattern
+ /(api-key:? )[^\s]+/, // Header: API key pattern
+ /(key=)[^\s]+/, // URL query param: sensitive key pattern (Google)
+];
+
+/**
+ * Determines if a given value string is sensitive and returns matching regex patterns.
+ *
+ * @param {string} valueStr - The value string to check.
+ * @returns {Array} An array of regex patterns that match the value string.
+ */
+function getMatchingSensitivePatterns(valueStr) {
+ if (valueStr) {
+ // Filter and return all regex patterns that match the value string
+ return sensitiveKeys.filter((regex) => regex.test(valueStr));
+ }
+ return [];
+}
+
+/**
+ * Redacts sensitive information from a console message and trims it to a specified length if provided.
+ * @param {string} str - The console message to be redacted.
+ * @param {number} [trimLength] - The optional length at which to trim the redacted message.
+ * @returns {string} - The redacted and optionally trimmed console message.
+ */
+function redactMessage(str, trimLength) {
+ if (!str) {
+ return '';
+ }
+
+ const patterns = getMatchingSensitivePatterns(str);
+ patterns.forEach((pattern) => {
+ str = str.replace(pattern, '$1[REDACTED]');
+ });
+
+ if (trimLength !== undefined && str.length > trimLength) {
+ return `${str.substring(0, trimLength)}...`;
+ }
+
+ return str;
+}
+
+/**
+ * Redacts sensitive information from log messages if the log level is 'error'.
+ * Note: Intentionally mutates the object.
+ * @param {Object} info - The log information object.
+ * @returns {Object} - The modified log information object.
+ */
+const redactFormat = winston.format((info) => {
+ if (info.level === 'error') {
+ info.message = redactMessage(info.message);
+ if (info[MESSAGE_SYMBOL]) {
+ info[MESSAGE_SYMBOL] = redactMessage(info[MESSAGE_SYMBOL]);
+ }
+ }
+ return info;
+});
+
+/**
+ * Truncates long strings, especially base64 image data, within log messages.
+ *
+ * @param {any} value - The value to be inspected and potentially truncated.
+ * @param {number} [length] - The length at which to truncate the value. Default: 100.
+ * @returns {any} - The truncated or original value.
+ */
+const truncateLongStrings = (value, length = 100) => {
+ if (typeof value === 'string') {
+ return value.length > length ? value.substring(0, length) + '... [truncated]' : value;
+ }
+
+ return value;
+};
+
+/**
+ * An array mapping function that truncates long strings (objects converted to JSON strings).
+ * @param {any} item - The item to be condensed.
+ * @returns {any} - The condensed item.
+ */
+const condenseArray = (item) => {
+ if (typeof item === 'string') {
+ return truncateLongStrings(JSON.stringify(item));
+ } else if (typeof item === 'object') {
+ return truncateLongStrings(JSON.stringify(item));
+ }
+ return item;
+};
+
+/**
+ * Formats log messages for debugging purposes.
+ * - Truncates long strings within log messages.
+ * - Condenses arrays by truncating long strings and objects as strings within array items.
+ * - Redacts sensitive information from log messages if the log level is 'error'.
+ * - Converts log information object to a formatted string.
+ *
+ * @param {Object} options - The options for formatting log messages.
+ * @param {string} options.level - The log level.
+ * @param {string} options.message - The log message.
+ * @param {string} options.timestamp - The timestamp of the log message.
+ * @param {Object} options.metadata - Additional metadata associated with the log message.
+ * @returns {string} - The formatted log message.
+ */
+const debugTraverse = winston.format.printf(({ level, message, timestamp, ...metadata }) => {
+ if (!message) {
+ return `${timestamp} ${level}`;
+ }
+
+ if (!message?.trim || typeof message !== 'string') {
+ return `${timestamp} ${level}: ${JSON.stringify(message)}`;
+ }
+
+ let msg = `${timestamp} ${level}: ${truncateLongStrings(message?.trim(), DEBUG_MESSAGE_LENGTH)}`;
+ try {
+ if (level !== 'debug') {
+ return msg;
+ }
+
+ if (!metadata) {
+ return msg;
+ }
+
+ const debugValue = metadata[SPLAT_SYMBOL]?.[0];
+
+ if (!debugValue) {
+ return msg;
+ }
+
+ if (debugValue && Array.isArray(debugValue)) {
+ msg += `\n${JSON.stringify(debugValue.map(condenseArray))}`;
+ return msg;
+ }
+
+ if (typeof debugValue !== 'object') {
+ return (msg += ` ${debugValue}`);
+ }
+
+ msg += '\n{';
+
+ const copy = klona(metadata);
+ traverse(copy).forEach(function (value) {
+ if (typeof this?.key === 'symbol') {
+ return;
+ }
+
+ let _parentKey = '';
+ const parent = this.parent;
+
+ if (typeof parent?.key !== 'symbol' && parent?.key) {
+ _parentKey = parent.key;
+ }
+
+ const parentKey = `${parent && parent.notRoot ? _parentKey + '.' : ''}`;
+
+ const tabs = `${parent && parent.notRoot ? ' ' : ' '}`;
+
+ const currentKey = this?.key ?? 'unknown';
+
+ if (this.isLeaf && typeof value === 'string') {
+ const truncatedText = truncateLongStrings(value);
+ msg += `\n${tabs}${parentKey}${currentKey}: ${JSON.stringify(truncatedText)},`;
+ } else if (this.notLeaf && Array.isArray(value) && value.length > 0) {
+ const currentMessage = `\n${tabs}// ${value.length} ${currentKey.replace(/s$/, '')}(s)`;
+ this.update(currentMessage, true);
+ msg += currentMessage;
+ const stringifiedArray = value.map(condenseArray);
+ msg += `\n${tabs}${parentKey}${currentKey}: [${stringifiedArray}],`;
+ } else if (this.isLeaf && typeof value === 'function') {
+ msg += `\n${tabs}${parentKey}${currentKey}: function,`;
+ } else if (this.isLeaf) {
+ msg += `\n${tabs}${parentKey}${currentKey}: ${value},`;
+ }
+ });
+
+ msg += '\n}';
+ return msg;
+ } catch (e) {
+ return (msg += `\n[LOGGER PARSING ERROR] ${e.message}`);
+ }
+});
+
+const jsonTruncateFormat = winston.format((info) => {
+ const truncateLongStrings = (str, maxLength) => {
+ return str.length > maxLength ? str.substring(0, maxLength) + '...' : str;
+ };
+
+ const seen = new WeakSet();
+
+ const truncateObject = (obj) => {
+ if (typeof obj !== 'object' || obj === null) {
+ return obj;
+ }
+
+ // Handle circular references
+ if (seen.has(obj)) {
+ return '[Circular]';
+ }
+ seen.add(obj);
+
+ if (Array.isArray(obj)) {
+ return obj.map((item) => truncateObject(item));
+ }
+
+ const newObj = {};
+ Object.entries(obj).forEach(([key, value]) => {
+ if (typeof value === 'string') {
+ newObj[key] = truncateLongStrings(value, CONSOLE_JSON_STRING_LENGTH);
+ } else {
+ newObj[key] = truncateObject(value);
+ }
+ });
+ return newObj;
+ };
+
+ return truncateObject(info);
+});
+
+module.exports = {
+ redactFormat,
+ redactMessage,
+ debugTraverse,
+ jsonTruncateFormat,
+};
diff --git a/api/config/paths.js b/api/config/paths.js
new file mode 100644
index 0000000000..165e9e6cd4
--- /dev/null
+++ b/api/config/paths.js
@@ -0,0 +1,14 @@
+const path = require('path');
+
+module.exports = {
+ root: path.resolve(__dirname, '..', '..'),
+ uploads: path.resolve(__dirname, '..', '..', 'uploads'),
+ clientPath: path.resolve(__dirname, '..', '..', 'client'),
+ dist: path.resolve(__dirname, '..', '..', 'client', 'dist'),
+ publicPath: path.resolve(__dirname, '..', '..', 'client', 'public'),
+ fonts: path.resolve(__dirname, '..', '..', 'client', 'public', 'fonts'),
+ assets: path.resolve(__dirname, '..', '..', 'client', 'public', 'assets'),
+ imageOutput: path.resolve(__dirname, '..', '..', 'client', 'public', 'images'),
+ structuredTools: path.resolve(__dirname, '..', 'app', 'clients', 'tools', 'structured'),
+ pluginManifest: path.resolve(__dirname, '..', 'app', 'clients', 'tools', 'manifest.json'),
+};
diff --git a/api/config/winston.js b/api/config/winston.js
new file mode 100644
index 0000000000..12f6053723
--- /dev/null
+++ b/api/config/winston.js
@@ -0,0 +1,132 @@
+const path = require('path');
+const winston = require('winston');
+require('winston-daily-rotate-file');
+const { redactFormat, redactMessage, debugTraverse, jsonTruncateFormat } = require('./parsers');
+
+const logDir = path.join(__dirname, '..', 'logs');
+
+const { NODE_ENV, DEBUG_LOGGING = true, CONSOLE_JSON = false, DEBUG_CONSOLE = false } = process.env;
+
+const useConsoleJson =
+ (typeof CONSOLE_JSON === 'string' && CONSOLE_JSON?.toLowerCase() === 'true') ||
+ CONSOLE_JSON === true;
+
+const useDebugConsole =
+ (typeof DEBUG_CONSOLE === 'string' && DEBUG_CONSOLE?.toLowerCase() === 'true') ||
+ DEBUG_CONSOLE === true;
+
+const useDebugLogging =
+ (typeof DEBUG_LOGGING === 'string' && DEBUG_LOGGING?.toLowerCase() === 'true') ||
+ DEBUG_LOGGING === true;
+
+const levels = {
+ error: 0,
+ warn: 1,
+ info: 2,
+ http: 3,
+ verbose: 4,
+ debug: 5,
+ activity: 6,
+ silly: 7,
+};
+
+winston.addColors({
+ info: 'green', // fontStyle color
+ warn: 'italic yellow',
+ error: 'red',
+ debug: 'blue',
+});
+
+const level = () => {
+ const env = NODE_ENV || 'development';
+ const isDevelopment = env === 'development';
+ return isDevelopment ? 'debug' : 'warn';
+};
+
+const fileFormat = winston.format.combine(
+ redactFormat(),
+ winston.format.timestamp({ format: () => new Date().toISOString() }),
+ winston.format.errors({ stack: true }),
+ winston.format.splat(),
+ // redactErrors(),
+);
+
+const transports = [
+ new winston.transports.DailyRotateFile({
+ level: 'error',
+ filename: `${logDir}/error-%DATE%.log`,
+ datePattern: 'YYYY-MM-DD',
+ zippedArchive: true,
+ maxSize: '20m',
+ maxFiles: '14d',
+ format: fileFormat,
+ }),
+];
+
+if (useDebugLogging) {
+ transports.push(
+ new winston.transports.DailyRotateFile({
+ level: 'debug',
+ filename: `${logDir}/debug-%DATE%.log`,
+ datePattern: 'YYYY-MM-DD',
+ zippedArchive: true,
+ maxSize: '20m',
+ maxFiles: '14d',
+ format: winston.format.combine(fileFormat, debugTraverse),
+ }),
+ );
+}
+
+const consoleFormat = winston.format.combine(
+ redactFormat(),
+ winston.format.colorize({ all: true }),
+ winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
+ // redactErrors(),
+ winston.format.printf((info) => {
+ const message = `${info.timestamp} ${info.level}: ${info.message}`;
+ if (info.level.includes('error')) {
+ return redactMessage(message);
+ }
+
+ return message;
+ }),
+);
+
+// Determine console log level
+let consoleLogLevel = 'info';
+if (useDebugConsole) {
+ consoleLogLevel = 'debug';
+}
+
+if (useDebugConsole) {
+ transports.push(
+ new winston.transports.Console({
+ level: consoleLogLevel,
+ format: useConsoleJson
+ ? winston.format.combine(fileFormat, jsonTruncateFormat(), winston.format.json())
+ : winston.format.combine(fileFormat, debugTraverse),
+ }),
+ );
+} else if (useConsoleJson) {
+ transports.push(
+ new winston.transports.Console({
+ level: consoleLogLevel,
+ format: winston.format.combine(fileFormat, jsonTruncateFormat(), winston.format.json()),
+ }),
+ );
+} else {
+ transports.push(
+ new winston.transports.Console({
+ level: consoleLogLevel,
+ format: consoleFormat,
+ }),
+ );
+}
+
+const logger = winston.createLogger({
+ level: level(),
+ levels,
+ transports,
+});
+
+module.exports = logger;
diff --git a/api/db/connect.js b/api/db/connect.js
new file mode 100644
index 0000000000..26166ccff8
--- /dev/null
+++ b/api/db/connect.js
@@ -0,0 +1,79 @@
+require('dotenv').config();
+const { isEnabled } = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+
+const mongoose = require('mongoose');
+const MONGO_URI = process.env.MONGO_URI;
+
+if (!MONGO_URI) {
+ throw new Error('Please define the MONGO_URI environment variable');
+}
+/** The maximum number of connections in the connection pool. */
+const maxPoolSize = parseInt(process.env.MONGO_MAX_POOL_SIZE) || undefined;
+/** The minimum number of connections in the connection pool. */
+const minPoolSize = parseInt(process.env.MONGO_MIN_POOL_SIZE) || undefined;
+/** The maximum number of connections that may be in the process of being established concurrently by the connection pool. */
+const maxConnecting = parseInt(process.env.MONGO_MAX_CONNECTING) || undefined;
+/** The maximum number of milliseconds that a connection can remain idle in the pool before being removed and closed. */
+const maxIdleTimeMS = parseInt(process.env.MONGO_MAX_IDLE_TIME_MS) || undefined;
+/** The maximum time in milliseconds that a thread can wait for a connection to become available. */
+const waitQueueTimeoutMS = parseInt(process.env.MONGO_WAIT_QUEUE_TIMEOUT_MS) || undefined;
+/** Set to false to disable automatic index creation for all models associated with this connection. */
+const autoIndex =
+ process.env.MONGO_AUTO_INDEX != undefined
+ ? isEnabled(process.env.MONGO_AUTO_INDEX) || false
+ : undefined;
+
+/** Set to `false` to disable Mongoose automatically calling `createCollection()` on every model created on this connection. */
+const autoCreate =
+ process.env.MONGO_AUTO_CREATE != undefined
+ ? isEnabled(process.env.MONGO_AUTO_CREATE) || false
+ : undefined;
+/**
+ * Global is used here to maintain a cached connection across hot reloads
+ * in development. This prevents connections growing exponentially
+ * during API Route usage.
+ */
+let cached = global.mongoose;
+
+if (!cached) {
+ cached = global.mongoose = { conn: null, promise: null };
+}
+
+async function connectDb() {
+ if (cached.conn && cached.conn?._readyState === 1) {
+ return cached.conn;
+ }
+
+ const disconnected = cached.conn && cached.conn?._readyState !== 1;
+ if (!cached.promise || disconnected) {
+ const opts = {
+ bufferCommands: false,
+ ...(maxPoolSize ? { maxPoolSize } : {}),
+ ...(minPoolSize ? { minPoolSize } : {}),
+ ...(maxConnecting ? { maxConnecting } : {}),
+ ...(maxIdleTimeMS ? { maxIdleTimeMS } : {}),
+ ...(waitQueueTimeoutMS ? { waitQueueTimeoutMS } : {}),
+ ...(autoIndex != undefined ? { autoIndex } : {}),
+ ...(autoCreate != undefined ? { autoCreate } : {}),
+ // useNewUrlParser: true,
+ // useUnifiedTopology: true,
+ // bufferMaxEntries: 0,
+ // useFindAndModify: true,
+ // useCreateIndex: true
+ };
+ logger.info('Mongo Connection options');
+ logger.info(JSON.stringify(opts, null, 2));
+ mongoose.set('strictQuery', true);
+ cached.promise = mongoose.connect(MONGO_URI, opts).then((mongoose) => {
+ return mongoose;
+ });
+ }
+ cached.conn = await cached.promise;
+
+ return cached.conn;
+}
+
+module.exports = {
+ connectDb,
+};
diff --git a/api/db/index.js b/api/db/index.js
new file mode 100644
index 0000000000..5c29902f69
--- /dev/null
+++ b/api/db/index.js
@@ -0,0 +1,8 @@
+const mongoose = require('mongoose');
+const { createModels } = require('@librechat/data-schemas');
+const { connectDb } = require('./connect');
+const indexSync = require('./indexSync');
+
+createModels(mongoose);
+
+module.exports = { connectDb, indexSync };
diff --git a/api/db/indexSync.js b/api/db/indexSync.js
new file mode 100644
index 0000000000..c86598d108
--- /dev/null
+++ b/api/db/indexSync.js
@@ -0,0 +1,360 @@
+const mongoose = require('mongoose');
+const { MeiliSearch } = require('meilisearch');
+const { logger } = require('@librechat/data-schemas');
+const { CacheKeys } = require('librechat-data-provider');
+const { isEnabled, FlowStateManager } = require('@librechat/api');
+const { getLogStores } = require('~/cache');
+
+const Conversation = mongoose.models.Conversation;
+const Message = mongoose.models.Message;
+
+const searchEnabled = isEnabled(process.env.SEARCH);
+const indexingDisabled = isEnabled(process.env.MEILI_NO_SYNC);
+let currentTimeout = null;
+
+class MeiliSearchClient {
+ static instance = null;
+
+ static getInstance() {
+ if (!MeiliSearchClient.instance) {
+ if (!process.env.MEILI_HOST || !process.env.MEILI_MASTER_KEY) {
+ throw new Error('Meilisearch configuration is missing.');
+ }
+ MeiliSearchClient.instance = new MeiliSearch({
+ host: process.env.MEILI_HOST,
+ apiKey: process.env.MEILI_MASTER_KEY,
+ });
+ }
+ return MeiliSearchClient.instance;
+ }
+}
+
+/**
+ * Deletes documents from MeiliSearch index that are missing the user field
+ * @param {import('meilisearch').Index} index - MeiliSearch index instance
+ * @param {string} indexName - Name of the index for logging
+ * @returns {Promise} - Number of documents deleted
+ */
+async function deleteDocumentsWithoutUserField(index, indexName) {
+ let deletedCount = 0;
+ let offset = 0;
+ const batchSize = 1000;
+
+ try {
+ while (true) {
+ const searchResult = await index.search('', {
+ limit: batchSize,
+ offset: offset,
+ });
+
+ if (searchResult.hits.length === 0) {
+ break;
+ }
+
+ const idsToDelete = searchResult.hits.filter((hit) => !hit.user).map((hit) => hit.id);
+
+ if (idsToDelete.length > 0) {
+ logger.info(
+ `[indexSync] Deleting ${idsToDelete.length} documents without user field from ${indexName} index`,
+ );
+ await index.deleteDocuments(idsToDelete);
+ deletedCount += idsToDelete.length;
+ }
+
+ if (searchResult.hits.length < batchSize) {
+ break;
+ }
+
+ offset += batchSize;
+ }
+
+ if (deletedCount > 0) {
+ logger.info(`[indexSync] Deleted ${deletedCount} orphaned documents from ${indexName} index`);
+ }
+ } catch (error) {
+ logger.error(`[indexSync] Error deleting documents from ${indexName}:`, error);
+ }
+
+ return deletedCount;
+}
+
+/**
+ * Ensures indexes have proper filterable attributes configured and checks if documents have user field
+ * @param {MeiliSearch} client - MeiliSearch client instance
+ * @returns {Promise<{settingsUpdated: boolean, orphanedDocsFound: boolean}>} - Status of what was done
+ */
+async function ensureFilterableAttributes(client) {
+ let settingsUpdated = false;
+ let hasOrphanedDocs = false;
+
+ try {
+ // Check and update messages index
+ try {
+ const messagesIndex = client.index('messages');
+ const settings = await messagesIndex.getSettings();
+
+ if (!settings.filterableAttributes || !settings.filterableAttributes.includes('user')) {
+ logger.info('[indexSync] Configuring messages index to filter by user...');
+ await messagesIndex.updateSettings({
+ filterableAttributes: ['user'],
+ });
+ logger.info('[indexSync] Messages index configured for user filtering');
+ settingsUpdated = true;
+ }
+
+ // Check if existing documents have user field indexed
+ try {
+ const searchResult = await messagesIndex.search('', { limit: 1 });
+ if (searchResult.hits.length > 0 && !searchResult.hits[0].user) {
+ logger.info(
+ '[indexSync] Existing messages missing user field, will clean up orphaned documents...',
+ );
+ hasOrphanedDocs = true;
+ }
+ } catch (searchError) {
+ logger.debug('[indexSync] Could not check message documents:', searchError.message);
+ }
+ } catch (error) {
+ if (error.code !== 'index_not_found') {
+ logger.warn('[indexSync] Could not check/update messages index settings:', error.message);
+ }
+ }
+
+ // Check and update conversations index
+ try {
+ const convosIndex = client.index('convos');
+ const settings = await convosIndex.getSettings();
+
+ if (!settings.filterableAttributes || !settings.filterableAttributes.includes('user')) {
+ logger.info('[indexSync] Configuring convos index to filter by user...');
+ await convosIndex.updateSettings({
+ filterableAttributes: ['user'],
+ });
+ logger.info('[indexSync] Convos index configured for user filtering');
+ settingsUpdated = true;
+ }
+
+ // Check if existing documents have user field indexed
+ try {
+ const searchResult = await convosIndex.search('', { limit: 1 });
+ if (searchResult.hits.length > 0 && !searchResult.hits[0].user) {
+ logger.info(
+ '[indexSync] Existing conversations missing user field, will clean up orphaned documents...',
+ );
+ hasOrphanedDocs = true;
+ }
+ } catch (searchError) {
+ logger.debug('[indexSync] Could not check conversation documents:', searchError.message);
+ }
+ } catch (error) {
+ if (error.code !== 'index_not_found') {
+ logger.warn('[indexSync] Could not check/update convos index settings:', error.message);
+ }
+ }
+
+ // If either index has orphaned documents, clean them up (but don't force resync)
+ if (hasOrphanedDocs) {
+ try {
+ const messagesIndex = client.index('messages');
+ await deleteDocumentsWithoutUserField(messagesIndex, 'messages');
+ } catch (error) {
+ logger.debug('[indexSync] Could not clean up messages:', error.message);
+ }
+
+ try {
+ const convosIndex = client.index('convos');
+ await deleteDocumentsWithoutUserField(convosIndex, 'convos');
+ } catch (error) {
+ logger.debug('[indexSync] Could not clean up convos:', error.message);
+ }
+
+ logger.info('[indexSync] Orphaned documents cleaned up without forcing resync.');
+ }
+
+ if (settingsUpdated) {
+ logger.info('[indexSync] Index settings updated. Full re-sync will be triggered.');
+ }
+ } catch (error) {
+ logger.error('[indexSync] Error ensuring filterable attributes:', error);
+ }
+
+ return { settingsUpdated, orphanedDocsFound: hasOrphanedDocs };
+}
+
+/**
+ * Performs the actual sync operations for messages and conversations
+ * @param {FlowStateManager} flowManager - Flow state manager instance
+ * @param {string} flowId - Flow identifier
+ * @param {string} flowType - Flow type
+ */
+async function performSync(flowManager, flowId, flowType) {
+ try {
+ const client = MeiliSearchClient.getInstance();
+
+ const { status } = await client.health();
+ if (status !== 'available') {
+ throw new Error('Meilisearch not available');
+ }
+
+ if (indexingDisabled === true) {
+ logger.info('[indexSync] Indexing is disabled, skipping...');
+ return { messagesSync: false, convosSync: false };
+ }
+
+ /** Ensures indexes have proper filterable attributes configured */
+ const { settingsUpdated, orphanedDocsFound: _orphanedDocsFound } =
+ await ensureFilterableAttributes(client);
+
+ let messagesSync = false;
+ let convosSync = false;
+
+ // Only reset flags if settings were actually updated (not just for orphaned doc cleanup)
+ if (settingsUpdated) {
+ logger.info(
+ '[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...',
+ );
+
+ // Reset sync flags to force full re-sync
+ await Message.collection.updateMany({ _meiliIndex: true }, { $set: { _meiliIndex: false } });
+ await Conversation.collection.updateMany(
+ { _meiliIndex: true },
+ { $set: { _meiliIndex: false } },
+ );
+ }
+
+ // Check if we need to sync messages
+ const messageProgress = await Message.getSyncProgress();
+ if (!messageProgress.isComplete || settingsUpdated) {
+ logger.info(
+ `[indexSync] Messages need syncing: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments} indexed`,
+ );
+
+ // Check if we should do a full sync or incremental
+ const messageCount = await Message.countDocuments();
+ const messagesIndexed = messageProgress.totalProcessed;
+ const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10);
+
+ if (messageCount - messagesIndexed > syncThreshold) {
+ logger.info('[indexSync] Starting full message sync due to large difference');
+ await Message.syncWithMeili();
+ messagesSync = true;
+ } else if (messageCount !== messagesIndexed) {
+ logger.warn('[indexSync] Messages out of sync, performing incremental sync');
+ await Message.syncWithMeili();
+ messagesSync = true;
+ }
+ } else {
+ logger.info(
+ `[indexSync] Messages are fully synced: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments}`,
+ );
+ }
+
+ // Check if we need to sync conversations
+ const convoProgress = await Conversation.getSyncProgress();
+ if (!convoProgress.isComplete || settingsUpdated) {
+ logger.info(
+ `[indexSync] Conversations need syncing: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments} indexed`,
+ );
+
+ const convoCount = await Conversation.countDocuments();
+ const convosIndexed = convoProgress.totalProcessed;
+ const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10);
+
+ if (convoCount - convosIndexed > syncThreshold) {
+ logger.info('[indexSync] Starting full conversation sync due to large difference');
+ await Conversation.syncWithMeili();
+ convosSync = true;
+ } else if (convoCount !== convosIndexed) {
+ logger.warn('[indexSync] Convos out of sync, performing incremental sync');
+ await Conversation.syncWithMeili();
+ convosSync = true;
+ }
+ } else {
+ logger.info(
+ `[indexSync] Conversations are fully synced: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments}`,
+ );
+ }
+
+ return { messagesSync, convosSync };
+ } finally {
+ if (indexingDisabled === true) {
+ logger.info('[indexSync] Indexing is disabled, skipping cleanup...');
+ } else if (flowManager && flowId && flowType) {
+ try {
+ await flowManager.deleteFlow(flowId, flowType);
+ logger.debug('[indexSync] Flow state cleaned up');
+ } catch (cleanupErr) {
+ logger.debug('[indexSync] Could not clean up flow state:', cleanupErr.message);
+ }
+ }
+ }
+}
+
+/**
+ * Main index sync function that uses FlowStateManager to prevent concurrent execution
+ */
+async function indexSync() {
+ if (!searchEnabled) {
+ return;
+ }
+
+ logger.info('[indexSync] Starting index synchronization check...');
+
+ // Get or create FlowStateManager instance
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ if (!flowsCache) {
+ logger.warn('[indexSync] Flows cache not available, falling back to direct sync');
+ return await performSync(null, null, null);
+ }
+
+ const flowManager = new FlowStateManager(flowsCache, {
+ ttl: 60000 * 10, // 10 minutes TTL for sync operations
+ });
+
+ // Use a unique flow ID for the sync operation
+ const flowId = 'meili-index-sync';
+ const flowType = 'MEILI_SYNC';
+
+ try {
+ // This will only execute the handler if no other instance is running the sync
+ const result = await flowManager.createFlowWithHandler(flowId, flowType, () =>
+ performSync(flowManager, flowId, flowType),
+ );
+
+ if (result.messagesSync || result.convosSync) {
+ logger.info('[indexSync] Sync completed successfully');
+ } else {
+ logger.debug('[indexSync] No sync was needed');
+ }
+
+ return result;
+ } catch (err) {
+ if (err.message.includes('flow already exists')) {
+ logger.info('[indexSync] Sync already running on another instance');
+ return;
+ }
+
+ if (err.message.includes('not found')) {
+ logger.debug('[indexSync] Creating indices...');
+ currentTimeout = setTimeout(async () => {
+ try {
+ await Message.syncWithMeili();
+ await Conversation.syncWithMeili();
+ } catch (err) {
+ logger.error('[indexSync] Trouble creating indices, try restarting the server.', err);
+ }
+ }, 750);
+ } else if (err.message.includes('Meilisearch not configured')) {
+ logger.info('[indexSync] Meilisearch not configured, search will be disabled.');
+ } else {
+ logger.error('[indexSync] error', err);
+ }
+ }
+}
+
+process.on('exit', () => {
+ logger.debug('[indexSync] Clearing sync timeouts before exiting...');
+ clearTimeout(currentTimeout);
+});
+
+module.exports = indexSync;
diff --git a/api/db/models.js b/api/db/models.js
new file mode 100644
index 0000000000..fca1327446
--- /dev/null
+++ b/api/db/models.js
@@ -0,0 +1,5 @@
+const mongoose = require('mongoose');
+const { createModels } = require('@librechat/data-schemas');
+const models = createModels(mongoose);
+
+module.exports = { ...models };
diff --git a/api/jest.config.js b/api/jest.config.js
new file mode 100644
index 0000000000..20ee3c6aed
--- /dev/null
+++ b/api/jest.config.js
@@ -0,0 +1,15 @@
+module.exports = {
+ testEnvironment: 'node',
+ clearMocks: true,
+ roots: [''],
+ coverageDirectory: 'coverage',
+ testTimeout: 30000, // 30 seconds timeout for all tests
+ setupFiles: ['./test/jestSetup.js', './test/__mocks__/logger.js'],
+ moduleNameMapper: {
+ '~/(.*)': '/$1',
+ '~/data/auth.json': '/__mocks__/auth.mock.json',
+ '^openid-client/passport$': '/test/__mocks__/openid-client-passport.js', // Mock for the passport strategy part
+ '^openid-client$': '/test/__mocks__/openid-client.js',
+ },
+ transformIgnorePatterns: ['/node_modules/(?!(openid-client|oauth4webapi|jose)/).*/'],
+};
diff --git a/api/jsconfig.json b/api/jsconfig.json
new file mode 100644
index 0000000000..756746fbf8
--- /dev/null
+++ b/api/jsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES6",
+ "module": "CommonJS",
+ // "checkJs": true, // Report errors in JavaScript files
+ "baseUrl": "./",
+ "paths": {
+ "*": ["*", "node_modules/*"],
+ "~/*": ["./*"]
+ }
+ },
+ "exclude": ["node_modules"]
+}
diff --git a/api/models/Action.js b/api/models/Action.js
new file mode 100644
index 0000000000..20aa20a7e4
--- /dev/null
+++ b/api/models/Action.js
@@ -0,0 +1,77 @@
+const { Action } = require('~/db/models');
+
+/**
+ * Update an action with new data without overwriting existing properties,
+ * or create a new action if it doesn't exist.
+ *
+ * @param {Object} searchParams - The search parameters to find the action to update.
+ * @param {string} searchParams.action_id - The ID of the action to update.
+ * @param {string} searchParams.user - The user ID of the action's author.
+ * @param {Object} updateData - An object containing the properties to update.
+ * @returns {Promise} The updated or newly created action document as a plain object.
+ */
+const updateAction = async (searchParams, updateData) => {
+ const options = { new: true, upsert: true };
+ return await Action.findOneAndUpdate(searchParams, updateData, options).lean();
+};
+
+/**
+ * Retrieves all actions that match the given search parameters.
+ *
+ * @param {Object} searchParams - The search parameters to find matching actions.
+ * @param {boolean} includeSensitive - Flag to include sensitive data in the metadata.
+ * @returns {Promise>} A promise that resolves to an array of action documents as plain objects.
+ */
+const getActions = async (searchParams, includeSensitive = false) => {
+ const actions = await Action.find(searchParams).lean();
+
+ if (!includeSensitive) {
+ for (let i = 0; i < actions.length; i++) {
+ const metadata = actions[i].metadata;
+ if (!metadata) {
+ continue;
+ }
+
+ const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
+ for (let field of sensitiveFields) {
+ if (metadata[field]) {
+ delete metadata[field];
+ }
+ }
+ }
+ }
+
+ return actions;
+};
+
+/**
+ * Deletes an action by params.
+ *
+ * @param {Object} searchParams - The search parameters to find the action to delete.
+ * @param {string} searchParams.action_id - The ID of the action to delete.
+ * @param {string} searchParams.user - The user ID of the action's author.
+ * @returns {Promise} A promise that resolves to the deleted action document as a plain object, or null if no document was found.
+ */
+const deleteAction = async (searchParams) => {
+ return await Action.findOneAndDelete(searchParams).lean();
+};
+
+/**
+ * Deletes actions by params.
+ *
+ * @param {Object} searchParams - The search parameters to find the actions to delete.
+ * @param {string} searchParams.action_id - The ID of the action(s) to delete.
+ * @param {string} searchParams.user - The user ID of the action's author.
+ * @returns {Promise} A promise that resolves to the number of deleted action documents.
+ */
+const deleteActions = async (searchParams) => {
+ const result = await Action.deleteMany(searchParams);
+ return result.deletedCount;
+};
+
+module.exports = {
+ getActions,
+ updateAction,
+ deleteAction,
+ deleteActions,
+};
diff --git a/api/models/Agent.js b/api/models/Agent.js
new file mode 100644
index 0000000000..5f171ef1f2
--- /dev/null
+++ b/api/models/Agent.js
@@ -0,0 +1,930 @@
+const mongoose = require('mongoose');
+const crypto = require('node:crypto');
+const { logger } = require('@librechat/data-schemas');
+const { ResourceType, SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider');
+const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_all, mcp_delimiter } =
+ require('librechat-data-provider').Constants;
+const {
+ removeAgentFromAllProjects,
+ removeAgentIdsFromProject,
+ addAgentIdsToProject,
+ getProjectByName,
+} = require('./Project');
+const { removeAllPermissions } = require('~/server/services/PermissionService');
+const { getMCPServerTools } = require('~/server/services/Config');
+const { Agent, AclEntry } = require('~/db/models');
+const { getActions } = require('./Action');
+
+/**
+ * Extracts unique MCP server names from tools array
+ * Tools format: "toolName_mcp_serverName" or "sys__server__sys_mcp_serverName"
+ * @param {string[]} tools - Array of tool identifiers
+ * @returns {string[]} Array of unique MCP server names
+ */
+const extractMCPServerNames = (tools) => {
+ if (!tools || !Array.isArray(tools)) {
+ return [];
+ }
+ const serverNames = new Set();
+ for (const tool of tools) {
+ if (!tool || !tool.includes(mcp_delimiter)) {
+ continue;
+ }
+ const parts = tool.split(mcp_delimiter);
+ if (parts.length >= 2) {
+ serverNames.add(parts[parts.length - 1]);
+ }
+ }
+ return Array.from(serverNames);
+};
+
+/**
+ * Create an agent with the provided data.
+ * @param {Object} agentData - The agent data to create.
+ * @returns {Promise} The created agent document as a plain object.
+ * @throws {Error} If the agent creation fails.
+ */
+const createAgent = async (agentData) => {
+ const { author: _author, ...versionData } = agentData;
+ const timestamp = new Date();
+ const initialAgentData = {
+ ...agentData,
+ versions: [
+ {
+ ...versionData,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ },
+ ],
+ category: agentData.category || 'general',
+ mcpServerNames: extractMCPServerNames(agentData.tools),
+ };
+
+ return (await Agent.create(initialAgentData)).toObject();
+};
+
+/**
+ * Get an agent document based on the provided ID.
+ *
+ * @param {Object} searchParameter - The search parameters to find the agent to update.
+ * @param {string} searchParameter.id - The ID of the agent to update.
+ * @param {string} searchParameter.author - The user ID of the agent's author.
+ * @returns {Promise} The agent document as a plain object, or null if not found.
+ */
+const getAgent = async (searchParameter) => await Agent.findOne(searchParameter).lean();
+
+/**
+ * Get multiple agent documents based on the provided search parameters.
+ *
+ * @param {Object} searchParameter - The search parameters to find agents.
+ * @returns {Promise} Array of agent documents as plain objects.
+ */
+const getAgents = async (searchParameter) => await Agent.find(searchParameter).lean();
+
+/**
+ * Load an agent based on the provided ID
+ *
+ * @param {Object} params
+ * @param {ServerRequest} params.req
+ * @param {string} params.spec
+ * @param {string} params.agent_id
+ * @param {string} params.endpoint
+ * @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
+ * @returns {Promise} The agent document as a plain object, or null if not found.
+ */
+const loadEphemeralAgent = async ({ req, spec, agent_id, endpoint, model_parameters: _m }) => {
+ const { model, ...model_parameters } = _m;
+ const modelSpecs = req.config?.modelSpecs?.list;
+ /** @type {TModelSpec | null} */
+ let modelSpec = null;
+ if (spec != null && spec !== '') {
+ modelSpec = modelSpecs?.find((s) => s.name === spec) || null;
+ }
+ /** @type {TEphemeralAgent | null} */
+ const ephemeralAgent = req.body.ephemeralAgent;
+ const mcpServers = new Set(ephemeralAgent?.mcp);
+ const userId = req.user?.id; // note: userId cannot be undefined at runtime
+ if (modelSpec?.mcpServers) {
+ for (const mcpServer of modelSpec.mcpServers) {
+ mcpServers.add(mcpServer);
+ }
+ }
+ /** @type {string[]} */
+ const tools = [];
+ if (ephemeralAgent?.execute_code === true || modelSpec?.executeCode === true) {
+ tools.push(Tools.execute_code);
+ }
+ if (ephemeralAgent?.file_search === true || modelSpec?.fileSearch === true) {
+ tools.push(Tools.file_search);
+ }
+ if (ephemeralAgent?.web_search === true || modelSpec?.webSearch === true) {
+ tools.push(Tools.web_search);
+ }
+
+ const addedServers = new Set();
+ if (mcpServers.size > 0) {
+ for (const mcpServer of mcpServers) {
+ if (addedServers.has(mcpServer)) {
+ continue;
+ }
+ const serverTools = await getMCPServerTools(userId, mcpServer);
+ if (!serverTools) {
+ tools.push(`${mcp_all}${mcp_delimiter}${mcpServer}`);
+ addedServers.add(mcpServer);
+ continue;
+ }
+ tools.push(...Object.keys(serverTools));
+ addedServers.add(mcpServer);
+ }
+ }
+
+ const instructions = req.body.promptPrefix;
+ const result = {
+ id: agent_id,
+ instructions,
+ provider: endpoint,
+ model_parameters,
+ model,
+ tools,
+ };
+
+ if (ephemeralAgent?.artifacts != null && ephemeralAgent.artifacts) {
+ result.artifacts = ephemeralAgent.artifacts;
+ }
+ return result;
+};
+
+/**
+ * Load an agent based on the provided ID
+ *
+ * @param {Object} params
+ * @param {ServerRequest} params.req
+ * @param {string} params.spec
+ * @param {string} params.agent_id
+ * @param {string} params.endpoint
+ * @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
+ * @returns {Promise} The agent document as a plain object, or null if not found.
+ */
+const loadAgent = async ({ req, spec, agent_id, endpoint, model_parameters }) => {
+ if (!agent_id) {
+ return null;
+ }
+ if (agent_id === EPHEMERAL_AGENT_ID) {
+ return await loadEphemeralAgent({ req, spec, agent_id, endpoint, model_parameters });
+ }
+ const agent = await getAgent({
+ id: agent_id,
+ });
+
+ if (!agent) {
+ return null;
+ }
+
+ agent.version = agent.versions ? agent.versions.length : 0;
+ return agent;
+};
+
+/**
+ * Check if a version already exists in the versions array, excluding timestamp and author fields
+ * @param {Object} updateData - The update data to compare
+ * @param {Object} currentData - The current agent data
+ * @param {Array} versions - The existing versions array
+ * @param {string} [actionsHash] - Hash of current action metadata
+ * @returns {Object|null} - The matching version if found, null otherwise
+ */
+const isDuplicateVersion = (updateData, currentData, versions, actionsHash = null) => {
+ if (!versions || versions.length === 0) {
+ return null;
+ }
+
+ const excludeFields = [
+ '_id',
+ 'id',
+ 'createdAt',
+ 'updatedAt',
+ 'author',
+ 'updatedBy',
+ 'created_at',
+ 'updated_at',
+ '__v',
+ 'versions',
+ 'actionsHash', // Exclude actionsHash from direct comparison
+ ];
+
+ const { $push: _$push, $pull: _$pull, $addToSet: _$addToSet, ...directUpdates } = updateData;
+
+ if (Object.keys(directUpdates).length === 0 && !actionsHash) {
+ return null;
+ }
+
+ const wouldBeVersion = { ...currentData, ...directUpdates };
+ const lastVersion = versions[versions.length - 1];
+
+ if (actionsHash && lastVersion.actionsHash !== actionsHash) {
+ return null;
+ }
+
+ const allFields = new Set([...Object.keys(wouldBeVersion), ...Object.keys(lastVersion)]);
+
+ const importantFields = Array.from(allFields).filter((field) => !excludeFields.includes(field));
+
+ let isMatch = true;
+ for (const field of importantFields) {
+ const wouldBeValue = wouldBeVersion[field];
+ const lastVersionValue = lastVersion[field];
+
+ // Skip if both are undefined/null
+ if (!wouldBeValue && !lastVersionValue) {
+ continue;
+ }
+
+ // Handle arrays
+ if (Array.isArray(wouldBeValue) || Array.isArray(lastVersionValue)) {
+ // Normalize: treat undefined/null as empty array for comparison
+ let wouldBeArr;
+ if (Array.isArray(wouldBeValue)) {
+ wouldBeArr = wouldBeValue;
+ } else if (wouldBeValue == null) {
+ wouldBeArr = [];
+ } else {
+ wouldBeArr = [wouldBeValue];
+ }
+
+ let lastVersionArr;
+ if (Array.isArray(lastVersionValue)) {
+ lastVersionArr = lastVersionValue;
+ } else if (lastVersionValue == null) {
+ lastVersionArr = [];
+ } else {
+ lastVersionArr = [lastVersionValue];
+ }
+
+ if (wouldBeArr.length !== lastVersionArr.length) {
+ isMatch = false;
+ break;
+ }
+
+ // Special handling for projectIds (MongoDB ObjectIds)
+ if (field === 'projectIds') {
+ const wouldBeIds = wouldBeArr.map((id) => id.toString()).sort();
+ const versionIds = lastVersionArr.map((id) => id.toString()).sort();
+
+ if (!wouldBeIds.every((id, i) => id === versionIds[i])) {
+ isMatch = false;
+ break;
+ }
+ }
+ // Handle arrays of objects
+ else if (
+ wouldBeArr.length > 0 &&
+ typeof wouldBeArr[0] === 'object' &&
+ wouldBeArr[0] !== null
+ ) {
+ const sortedWouldBe = [...wouldBeArr].map((item) => JSON.stringify(item)).sort();
+ const sortedVersion = [...lastVersionArr].map((item) => JSON.stringify(item)).sort();
+
+ if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
+ isMatch = false;
+ break;
+ }
+ } else {
+ const sortedWouldBe = [...wouldBeArr].sort();
+ const sortedVersion = [...lastVersionArr].sort();
+
+ if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
+ isMatch = false;
+ break;
+ }
+ }
+ }
+ // Handle objects
+ else if (typeof wouldBeValue === 'object' && wouldBeValue !== null) {
+ const lastVersionObj =
+ typeof lastVersionValue === 'object' && lastVersionValue !== null ? lastVersionValue : {};
+
+ // For empty objects, normalize the comparison
+ const wouldBeKeys = Object.keys(wouldBeValue);
+ const lastVersionKeys = Object.keys(lastVersionObj);
+
+ // If both are empty objects, they're equal
+ if (wouldBeKeys.length === 0 && lastVersionKeys.length === 0) {
+ continue;
+ }
+
+ // Otherwise do a deep comparison
+ if (JSON.stringify(wouldBeValue) !== JSON.stringify(lastVersionObj)) {
+ isMatch = false;
+ break;
+ }
+ }
+ // Handle primitive values
+ else {
+ // For primitives, handle the case where one is undefined and the other is a default value
+ if (wouldBeValue !== lastVersionValue) {
+ // Special handling for boolean false vs undefined
+ if (
+ typeof wouldBeValue === 'boolean' &&
+ wouldBeValue === false &&
+ lastVersionValue === undefined
+ ) {
+ continue;
+ }
+ // Special handling for empty string vs undefined
+ if (
+ typeof wouldBeValue === 'string' &&
+ wouldBeValue === '' &&
+ lastVersionValue === undefined
+ ) {
+ continue;
+ }
+ isMatch = false;
+ break;
+ }
+ }
+ }
+
+ return isMatch ? lastVersion : null;
+};
+
+/**
+ * Update an agent with new data without overwriting existing
+ * properties, or create a new agent if it doesn't exist.
+ * When an agent is updated, a copy of the current state will be saved to the versions array.
+ *
+ * @param {Object} searchParameter - The search parameters to find the agent to update.
+ * @param {string} searchParameter.id - The ID of the agent to update.
+ * @param {string} [searchParameter.author] - The user ID of the agent's author.
+ * @param {Object} updateData - An object containing the properties to update.
+ * @param {Object} [options] - Optional configuration object.
+ * @param {string} [options.updatingUserId] - The ID of the user performing the update (used for tracking non-author updates).
+ * @param {boolean} [options.forceVersion] - Force creation of a new version even if no fields changed.
+ * @param {boolean} [options.skipVersioning] - Skip version creation entirely (useful for isolated operations like sharing).
+ * @returns {Promise} The updated or newly created agent document as a plain object.
+ * @throws {Error} If the update would create a duplicate version
+ */
+const updateAgent = async (searchParameter, updateData, options = {}) => {
+ const { updatingUserId = null, forceVersion = false, skipVersioning = false } = options;
+ const mongoOptions = { new: true, upsert: false };
+
+ const currentAgent = await Agent.findOne(searchParameter);
+ if (currentAgent) {
+ const {
+ __v,
+ _id,
+ id: __id,
+ versions,
+ author: _author,
+ ...versionData
+ } = currentAgent.toObject();
+ const { $push, $pull, $addToSet, ...directUpdates } = updateData;
+
+ // Sync mcpServerNames when tools are updated
+ if (directUpdates.tools !== undefined) {
+ const mcpServerNames = extractMCPServerNames(directUpdates.tools);
+ directUpdates.mcpServerNames = mcpServerNames;
+ updateData.mcpServerNames = mcpServerNames; // Also update the original updateData
+ }
+
+ let actionsHash = null;
+
+ // Generate actions hash if agent has actions
+ if (currentAgent.actions && currentAgent.actions.length > 0) {
+ // Extract action IDs from the format "domain_action_id"
+ const actionIds = currentAgent.actions
+ .map((action) => {
+ const parts = action.split(actionDelimiter);
+ return parts[1]; // Get just the action ID part
+ })
+ .filter(Boolean);
+
+ if (actionIds.length > 0) {
+ try {
+ const actions = await getActions(
+ {
+ action_id: { $in: actionIds },
+ },
+ true,
+ ); // Include sensitive data for hash
+
+ actionsHash = await generateActionMetadataHash(currentAgent.actions, actions);
+ } catch (error) {
+ logger.error('Error fetching actions for hash generation:', error);
+ }
+ }
+ }
+
+ const shouldCreateVersion =
+ !skipVersioning &&
+ (forceVersion || Object.keys(directUpdates).length > 0 || $push || $pull || $addToSet);
+
+ if (shouldCreateVersion) {
+ const duplicateVersion = isDuplicateVersion(updateData, versionData, versions, actionsHash);
+ if (duplicateVersion && !forceVersion) {
+ // No changes detected, return the current agent without creating a new version
+ const agentObj = currentAgent.toObject();
+ agentObj.version = versions.length;
+ return agentObj;
+ }
+ }
+
+ const versionEntry = {
+ ...versionData,
+ ...directUpdates,
+ updatedAt: new Date(),
+ };
+
+ // Include actions hash in version if available
+ if (actionsHash) {
+ versionEntry.actionsHash = actionsHash;
+ }
+
+ // Always store updatedBy field to track who made the change
+ if (updatingUserId) {
+ versionEntry.updatedBy = new mongoose.Types.ObjectId(updatingUserId);
+ }
+
+ if (shouldCreateVersion) {
+ updateData.$push = {
+ ...($push || {}),
+ versions: versionEntry,
+ };
+ }
+ }
+
+ return Agent.findOneAndUpdate(searchParameter, updateData, mongoOptions).lean();
+};
+
+/**
+ * Modifies an agent with the resource file id.
+ * @param {object} params
+ * @param {ServerRequest} params.req
+ * @param {string} params.agent_id
+ * @param {string} params.tool_resource
+ * @param {string} params.file_id
+ * @returns {Promise} The updated agent.
+ */
+const addAgentResourceFile = async ({ req, 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,
+ [`${fileIdsPath}`]: { $exists: false },
+ },
+ {
+ $set: {
+ [`${fileIdsPath}`]: [],
+ },
+ },
+ );
+
+ const updateData = {
+ $addToSet: {
+ tools: tool_resource,
+ [fileIdsPath]: file_id,
+ },
+ };
+
+ const updatedAgent = await updateAgent(searchParameter, updateData, {
+ updatingUserId: req?.user?.id,
+ });
+ if (updatedAgent) {
+ return updatedAgent;
+ } else {
+ throw new Error('Agent not found for adding resource file');
+ }
+};
+
+/**
+ * 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} 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 };
+
+ // Group files to remove by resource
+ const filesByResource = files.reduce((acc, { tool_resource, file_id }) => {
+ if (!acc[tool_resource]) {
+ acc[tool_resource] = [];
+ }
+ acc[tool_resource].push(file_id);
+ return acc;
+ }, {});
+
+ // 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;
+};
+
+/**
+ * Deletes an agent based on the provided ID.
+ *
+ * @param {Object} searchParameter - The search parameters to find the agent to delete.
+ * @param {string} searchParameter.id - The ID of the agent to delete.
+ * @param {string} [searchParameter.author] - The user ID of the agent's author.
+ * @returns {Promise} Resolves when the agent has been successfully deleted.
+ */
+const deleteAgent = async (searchParameter) => {
+ const agent = await Agent.findOneAndDelete(searchParameter);
+ if (agent) {
+ await removeAgentFromAllProjects(agent.id);
+ await removeAllPermissions({
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ });
+ }
+ return agent;
+};
+
+/**
+ * Deletes all agents created by a specific user.
+ * @param {string} userId - The ID of the user whose agents should be deleted.
+ * @returns {Promise} A promise that resolves when all user agents have been deleted.
+ */
+const deleteUserAgents = async (userId) => {
+ try {
+ const userAgents = await getAgents({ author: userId });
+
+ if (userAgents.length === 0) {
+ return;
+ }
+
+ const agentIds = userAgents.map((agent) => agent.id);
+ const agentObjectIds = userAgents.map((agent) => agent._id);
+
+ for (const agentId of agentIds) {
+ await removeAgentFromAllProjects(agentId);
+ }
+
+ await AclEntry.deleteMany({
+ resourceType: ResourceType.AGENT,
+ resourceId: { $in: agentObjectIds },
+ });
+
+ await Agent.deleteMany({ author: userId });
+ } catch (error) {
+ logger.error('[deleteUserAgents] General error:', error);
+ }
+};
+
+/**
+ * Get agents by accessible IDs with optional cursor-based pagination.
+ * @param {Object} params - The parameters for getting accessible agents.
+ * @param {Array} [params.accessibleIds] - Array of agent ObjectIds the user has ACL access to.
+ * @param {Object} [params.otherParams] - Additional query parameters (including author filter).
+ * @param {number} [params.limit] - Number of agents to return (max 100). If not provided, returns all agents.
+ * @param {string} [params.after] - Cursor for pagination - get agents after this cursor. // base64 encoded JSON string with updatedAt and _id.
+ * @returns {Promise} A promise that resolves to an object containing the agents data and pagination info.
+ */
+const getListAgentsByAccess = async ({
+ accessibleIds = [],
+ otherParams = {},
+ limit = null,
+ after = null,
+}) => {
+ const isPaginated = limit !== null && limit !== undefined;
+ const normalizedLimit = isPaginated ? Math.min(Math.max(1, parseInt(limit) || 20), 100) : null;
+
+ // Build base query combining ACL accessible agents with other filters
+ const baseQuery = { ...otherParams, _id: { $in: accessibleIds } };
+
+ // Add cursor condition
+ if (after) {
+ try {
+ const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8'));
+ const { updatedAt, _id } = cursor;
+
+ const cursorCondition = {
+ $or: [
+ { updatedAt: { $lt: new Date(updatedAt) } },
+ { updatedAt: new Date(updatedAt), _id: { $gt: new mongoose.Types.ObjectId(_id) } },
+ ],
+ };
+
+ // Merge cursor condition with base query
+ if (Object.keys(baseQuery).length > 0) {
+ baseQuery.$and = [{ ...baseQuery }, cursorCondition];
+ // Remove the original conditions from baseQuery to avoid duplication
+ Object.keys(baseQuery).forEach((key) => {
+ if (key !== '$and') delete baseQuery[key];
+ });
+ } else {
+ Object.assign(baseQuery, cursorCondition);
+ }
+ } catch (error) {
+ logger.warn('Invalid cursor:', error.message);
+ }
+ }
+
+ let query = Agent.find(baseQuery, {
+ id: 1,
+ _id: 1,
+ name: 1,
+ avatar: 1,
+ author: 1,
+ projectIds: 1,
+ description: 1,
+ updatedAt: 1,
+ category: 1,
+ support_contact: 1,
+ is_promoted: 1,
+ }).sort({ updatedAt: -1, _id: 1 });
+
+ // Only apply limit if pagination is requested
+ if (isPaginated) {
+ query = query.limit(normalizedLimit + 1);
+ }
+
+ const agents = await query.lean();
+
+ const hasMore = isPaginated ? agents.length > normalizedLimit : false;
+ const data = (isPaginated ? agents.slice(0, normalizedLimit) : agents).map((agent) => {
+ if (agent.author) {
+ agent.author = agent.author.toString();
+ }
+ return agent;
+ });
+
+ // Generate next cursor only if paginated
+ let nextCursor = null;
+ if (isPaginated && hasMore && data.length > 0) {
+ const lastAgent = agents[normalizedLimit - 1];
+ nextCursor = Buffer.from(
+ JSON.stringify({
+ updatedAt: lastAgent.updatedAt.toISOString(),
+ _id: lastAgent._id.toString(),
+ }),
+ ).toString('base64');
+ }
+
+ return {
+ object: 'list',
+ data,
+ first_id: data.length > 0 ? data[0].id : null,
+ last_id: data.length > 0 ? data[data.length - 1].id : null,
+ has_more: hasMore,
+ after: nextCursor,
+ };
+};
+
+/**
+ * Get all agents.
+ * @deprecated Use getListAgentsByAccess for ACL-aware agent listing
+ * @param {Object} searchParameter - The search parameters to find matching agents.
+ * @param {string} searchParameter.author - The user ID of the agent's author.
+ * @returns {Promise} A promise that resolves to an object containing the agents data and pagination info.
+ */
+const getListAgents = async (searchParameter) => {
+ const { author, ...otherParams } = searchParameter;
+
+ let query = Object.assign({ author }, otherParams);
+
+ const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, ['agentIds']);
+ if (globalProject && (globalProject.agentIds?.length ?? 0) > 0) {
+ const globalQuery = { id: { $in: globalProject.agentIds }, ...otherParams };
+ delete globalQuery.author;
+ query = { $or: [globalQuery, query] };
+ }
+ const agents = (
+ await Agent.find(query, {
+ id: 1,
+ _id: 1,
+ name: 1,
+ avatar: 1,
+ author: 1,
+ projectIds: 1,
+ description: 1,
+ // @deprecated - isCollaborative replaced by ACL permissions
+ isCollaborative: 1,
+ category: 1,
+ }).lean()
+ ).map((agent) => {
+ if (agent.author?.toString() !== author) {
+ delete agent.author;
+ }
+ if (agent.author) {
+ agent.author = agent.author.toString();
+ }
+ return agent;
+ });
+
+ const hasMore = agents.length > 0;
+ const firstId = agents.length > 0 ? agents[0].id : null;
+ const lastId = agents.length > 0 ? agents[agents.length - 1].id : null;
+
+ return {
+ data: agents,
+ has_more: hasMore,
+ first_id: firstId,
+ last_id: lastId,
+ };
+};
+
+/**
+ * Updates the projects associated with an agent, adding and removing project IDs as specified.
+ * 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 {IUser} 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.
+ * @returns {Promise} The updated agent document.
+ * @throws {Error} If there's an error updating the agent or projects.
+ */
+const updateAgentProjects = async ({ user, agentId, projectIds, removeProjectIds }) => {
+ const updateOps = {};
+
+ if (removeProjectIds && removeProjectIds.length > 0) {
+ for (const projectId of removeProjectIds) {
+ await removeAgentIdsFromProject(projectId, [agentId]);
+ }
+ updateOps.$pull = { projectIds: { $in: removeProjectIds } };
+ }
+
+ if (projectIds && projectIds.length > 0) {
+ for (const projectId of projectIds) {
+ await addAgentIdsToProject(projectId, [agentId]);
+ }
+ updateOps.$addToSet = { projectIds: { $each: projectIds } };
+ }
+
+ if (Object.keys(updateOps).length === 0) {
+ return await getAgent({ id: agentId });
+ }
+
+ const updateQuery = { id: agentId, author: user.id };
+ if (user.role === SystemRoles.ADMIN) {
+ delete updateQuery.author;
+ }
+
+ const updatedAgent = await updateAgent(updateQuery, updateOps, {
+ updatingUserId: user.id,
+ skipVersioning: true,
+ });
+ if (updatedAgent) {
+ return updatedAgent;
+ }
+ if (updateOps.$addToSet) {
+ for (const projectId of projectIds) {
+ await removeAgentIdsFromProject(projectId, [agentId]);
+ }
+ } else if (updateOps.$pull) {
+ for (const projectId of removeProjectIds) {
+ await addAgentIdsToProject(projectId, [agentId]);
+ }
+ }
+
+ return await getAgent({ id: agentId });
+};
+
+/**
+ * Reverts an agent to a specific version in its version history.
+ * @param {Object} searchParameter - The search parameters to find the agent to revert.
+ * @param {string} searchParameter.id - The ID of the agent to revert.
+ * @param {string} [searchParameter.author] - The user ID of the agent's author.
+ * @param {number} versionIndex - The index of the version to revert to in the versions array.
+ * @returns {Promise} The updated agent document after reverting.
+ * @throws {Error} If the agent is not found or the specified version does not exist.
+ */
+const revertAgentVersion = async (searchParameter, versionIndex) => {
+ const agent = await Agent.findOne(searchParameter);
+ if (!agent) {
+ throw new Error('Agent not found');
+ }
+
+ if (!agent.versions || !agent.versions[versionIndex]) {
+ throw new Error(`Version ${versionIndex} not found`);
+ }
+
+ const revertToVersion = agent.versions[versionIndex];
+
+ const updateData = {
+ ...revertToVersion,
+ };
+
+ delete updateData._id;
+ delete updateData.id;
+ delete updateData.versions;
+ delete updateData.author;
+ delete updateData.updatedBy;
+
+ return Agent.findOneAndUpdate(searchParameter, updateData, { new: true }).lean();
+};
+
+/**
+ * Generates a hash of action metadata for version comparison
+ * @param {string[]} actionIds - Array of action IDs in format "domain_action_id"
+ * @param {Action[]} actions - Array of action documents
+ * @returns {Promise} - SHA256 hash of the action metadata
+ */
+const generateActionMetadataHash = async (actionIds, actions) => {
+ if (!actionIds || actionIds.length === 0) {
+ return '';
+ }
+
+ // Create a map of action_id to metadata for quick lookup
+ const actionMap = new Map();
+ actions.forEach((action) => {
+ actionMap.set(action.action_id, action.metadata);
+ });
+
+ // Sort action IDs for consistent hashing
+ const sortedActionIds = [...actionIds].sort();
+
+ // Build a deterministic string representation of all action metadata
+ const metadataString = sortedActionIds
+ .map((actionFullId) => {
+ // Extract just the action_id part (after the delimiter)
+ const parts = actionFullId.split(actionDelimiter);
+ const actionId = parts[1];
+
+ const metadata = actionMap.get(actionId);
+ if (!metadata) {
+ return `${actionId}:null`;
+ }
+
+ // Sort metadata keys for deterministic output
+ const sortedKeys = Object.keys(metadata).sort();
+ const metadataStr = sortedKeys
+ .map((key) => `${key}:${JSON.stringify(metadata[key])}`)
+ .join(',');
+ return `${actionId}:{${metadataStr}}`;
+ })
+ .join(';');
+
+ // Use Web Crypto API to generate hash
+ const encoder = new TextEncoder();
+ const data = encoder.encode(metadataString);
+ const hashBuffer = await crypto.webcrypto.subtle.digest('SHA-256', data);
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
+ const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
+
+ return hashHex;
+};
+/**
+ * Counts the number of promoted agents.
+ * @returns {Promise} - The count of promoted agents
+ */
+const countPromotedAgents = async () => {
+ const count = await Agent.countDocuments({ is_promoted: true });
+ return count;
+};
+
+/**
+ * Load a default agent based on the endpoint
+ * @param {string} endpoint
+ * @returns {Agent | null}
+ */
+
+module.exports = {
+ getAgent,
+ getAgents,
+ loadAgent,
+ createAgent,
+ updateAgent,
+ deleteAgent,
+ deleteUserAgents,
+ getListAgents,
+ revertAgentVersion,
+ updateAgentProjects,
+ addAgentResourceFile,
+ getListAgentsByAccess,
+ removeAgentResourceFiles,
+ generateActionMetadataHash,
+ countPromotedAgents,
+};
diff --git a/api/models/Agent.spec.js b/api/models/Agent.spec.js
new file mode 100644
index 0000000000..6c7db6121e
--- /dev/null
+++ b/api/models/Agent.spec.js
@@ -0,0 +1,3370 @@
+const originalEnv = {
+ CREDS_KEY: process.env.CREDS_KEY,
+ CREDS_IV: process.env.CREDS_IV,
+};
+
+process.env.CREDS_KEY = '0123456789abcdef0123456789abcdef';
+process.env.CREDS_IV = '0123456789abcdef';
+
+jest.mock('~/server/services/Config', () => ({
+ getCachedTools: jest.fn(),
+ getMCPServerTools: jest.fn(),
+}));
+
+const mongoose = require('mongoose');
+const { v4: uuidv4 } = require('uuid');
+const { agentSchema } = require('@librechat/data-schemas');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { AccessRoleIds, ResourceType, PrincipalType } = require('librechat-data-provider');
+const {
+ getAgent,
+ loadAgent,
+ createAgent,
+ updateAgent,
+ deleteAgent,
+ getListAgents,
+ getListAgentsByAccess,
+ revertAgentVersion,
+ updateAgentProjects,
+ addAgentResourceFile,
+ removeAgentResourceFiles,
+ generateActionMetadataHash,
+} = require('./Agent');
+const permissionService = require('~/server/services/PermissionService');
+const { getCachedTools, getMCPServerTools } = require('~/server/services/Config');
+const { AclEntry } = require('~/db/models');
+
+/**
+ * @type {import('mongoose').Model}
+ */
+let Agent;
+
+describe('models/Agent', () => {
+ describe('Agent Resource File Operations', () => {
+ let mongoServer;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
+ await mongoose.connect(mongoUri);
+ }, 20000);
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ process.env.CREDS_KEY = originalEnv.CREDS_KEY;
+ process.env.CREDS_IV = originalEnv.CREDS_IV;
+ });
+
+ beforeEach(async () => {
+ await Agent.deleteMany({});
+ });
+
+ 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());
+
+ // Concurrent additions
+ const additionPromises = createFileOperations(agent.id, fileIds, 'add');
+
+ await Promise.all(additionPromises);
+
+ const updatedAgent = await Agent.findOne({ id: agent.id });
+ expect(updatedAgent.tool_resources.test_tool.file_ids).toBeDefined();
+ expect(updatedAgent.tool_resources.test_tool.file_ids).toHaveLength(10);
+ expect(new Set(updatedAgent.tool_resources.test_tool.file_ids).size).toBe(10);
+ });
+
+ test('should handle concurrent additions and removals', async () => {
+ const agent = await createBasicAgent();
+ const initialFileIds = Array.from({ length: 5 }, () => uuidv4());
+
+ await Promise.all(createFileOperations(agent.id, initialFileIds, 'add'));
+
+ const newFileIds = Array.from({ length: 5 }, () => uuidv4());
+ const operations = [
+ ...newFileIds.map((fileId) =>
+ addAgentResourceFile({
+ agent_id: agent.id,
+ tool_resource: 'test_tool',
+ file_id: fileId,
+ }),
+ ),
+ ...initialFileIds.map((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 });
+ expect(updatedAgent.tool_resources.test_tool.file_ids).toBeDefined();
+ expect(updatedAgent.tool_resources.test_tool.file_ids).toHaveLength(5);
+ });
+
+ test('should initialize array when adding to non-existent tool resource', async () => {
+ const agent = await createBasicAgent();
+ const fileId = uuidv4();
+
+ const updatedAgent = await addAgentResourceFile({
+ agent_id: agent.id,
+ tool_resource: 'new_tool',
+ file_id: fileId,
+ });
+
+ expect(updatedAgent.tool_resources.new_tool.file_ids).toBeDefined();
+ expect(updatedAgent.tool_resources.new_tool.file_ids).toHaveLength(1);
+ expect(updatedAgent.tool_resources.new_tool.file_ids[0]).toBe(fileId);
+ });
+
+ test('should handle rapid sequential modifications to same tool resource', async () => {
+ const agent = await createBasicAgent();
+ const fileId = uuidv4();
+
+ for (let i = 0; i < 10; i++) {
+ await addAgentResourceFile({
+ agent_id: agent.id,
+ tool_resource: 'test_tool',
+ file_id: `${fileId}_${i}`,
+ });
+
+ if (i % 2 === 0) {
+ await removeAgentResourceFiles({
+ agent_id: agent.id,
+ files: [{ tool_resource: 'test_tool', file_id: `${fileId}_${i}` }],
+ });
+ }
+ }
+
+ const updatedAgent = await Agent.findOne({ id: agent.id });
+ expect(updatedAgent.tool_resources.test_tool.file_ids).toBeDefined();
+ expect(Array.isArray(updatedAgent.tool_resources.test_tool.file_ids)).toBe(true);
+ });
+
+ test('should handle multiple tool resources concurrently', async () => {
+ const agent = await createBasicAgent();
+ const toolResources = ['tool1', 'tool2', 'tool3'];
+ const operations = [];
+
+ toolResources.forEach((tool) => {
+ const fileIds = Array.from({ length: 5 }, () => uuidv4());
+ fileIds.forEach((fileId) => {
+ operations.push(
+ addAgentResourceFile({
+ agent_id: agent.id,
+ tool_resource: tool,
+ file_id: fileId,
+ }),
+ );
+ });
+ });
+
+ await Promise.all(operations);
+
+ const updatedAgent = await Agent.findOne({ id: agent.id });
+ toolResources.forEach((tool) => {
+ expect(updatedAgent.tool_resources[tool].file_ids).toBeDefined();
+ expect(updatedAgent.tool_resources[tool].file_ids).toHaveLength(5);
+ });
+ });
+
+ test.each([
+ {
+ name: 'duplicate additions',
+ operation: 'add',
+ duplicateCount: 5,
+ expectedLength: 1,
+ expectedContains: true,
+ },
+ {
+ name: 'duplicate removals',
+ operation: 'remove',
+ duplicateCount: 5,
+ expectedLength: 0,
+ expectedContains: false,
+ setupFile: true,
+ },
+ ])(
+ 'should handle concurrent $name',
+ async ({ operation, duplicateCount, expectedLength, expectedContains, setupFile }) => {
+ const agent = await createBasicAgent();
+ const fileId = uuidv4();
+
+ if (setupFile) {
+ await addAgentResourceFile({
+ agent_id: agent.id,
+ tool_resource: 'test_tool',
+ file_id: fileId,
+ });
+ }
+
+ const promises = Array.from({ length: duplicateCount }).map(() =>
+ operation === 'add'
+ ? 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(promises);
+
+ const updatedAgent = await Agent.findOne({ id: agent.id });
+ const fileIds = updatedAgent.tool_resources?.test_tool?.file_ids ?? [];
+
+ expect(fileIds).toHaveLength(expectedLength);
+ if (expectedContains) {
+ expect(fileIds[0]).toBe(fileId);
+ } else {
+ expect(fileIds).not.toContain(fileId);
+ }
+ },
+ );
+
+ test('should handle concurrent add and remove of the same file', async () => {
+ const agent = await createBasicAgent();
+ const fileId = uuidv4();
+
+ await addAgentResourceFile({
+ agent_id: agent.id,
+ tool_resource: 'test_tool',
+ file_id: fileId,
+ });
+
+ 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 });
+ const finalFileIds = updatedAgent.tool_resources.test_tool.file_ids;
+ const count = finalFileIds.filter((id) => id === fileId).length;
+
+ expect(count).toBeLessThanOrEqual(1);
+ if (count === 0) {
+ expect(finalFileIds).toHaveLength(0);
+ } else {
+ expect(finalFileIds).toHaveLength(1);
+ expect(finalFileIds[0]).toBe(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);
+ });
+
+ describe('Edge Cases', () => {
+ describe.each([
+ {
+ operation: 'add',
+ name: 'empty file_id',
+ needsAgent: true,
+ params: { tool_resource: 'file_search', file_id: '' },
+ shouldResolve: true,
+ },
+ {
+ operation: 'add',
+ name: 'non-existent agent',
+ needsAgent: false,
+ params: { tool_resource: 'file_search', file_id: 'file123' },
+ shouldResolve: false,
+ error: 'Agent not found for adding resource file',
+ },
+ ])('addAgentResourceFile with $name', ({ needsAgent, params, shouldResolve, error }) => {
+ test(`should ${shouldResolve ? 'resolve' : 'reject'}`, async () => {
+ const agent = needsAgent ? await createBasicAgent() : null;
+ const agent_id = needsAgent ? agent.id : `agent_${uuidv4()}`;
+
+ if (shouldResolve) {
+ await expect(addAgentResourceFile({ agent_id, ...params })).resolves.toBeDefined();
+ } else {
+ await expect(addAgentResourceFile({ agent_id, ...params })).rejects.toThrow(error);
+ }
+ });
+ });
+
+ describe.each([
+ {
+ name: 'empty files array',
+ files: [],
+ needsAgent: true,
+ shouldResolve: true,
+ },
+ {
+ name: 'non-existent tool_resource',
+ files: [{ tool_resource: 'non_existent_tool', file_id: 'file123' }],
+ needsAgent: true,
+ shouldResolve: true,
+ },
+ {
+ name: 'non-existent agent',
+ files: [{ tool_resource: 'file_search', file_id: 'file123' }],
+ needsAgent: false,
+ shouldResolve: false,
+ error: 'Agent not found for removing resource files',
+ },
+ ])('removeAgentResourceFiles with $name', ({ files, needsAgent, shouldResolve, error }) => {
+ test(`should ${shouldResolve ? 'resolve' : 'reject'}`, async () => {
+ const agent = needsAgent ? await createBasicAgent() : null;
+ const agent_id = needsAgent ? agent.id : `agent_${uuidv4()}`;
+
+ if (shouldResolve) {
+ const result = await removeAgentResourceFiles({ agent_id, files });
+ expect(result).toBeDefined();
+ if (agent) {
+ expect(result.id).toBe(agent.id);
+ }
+ } else {
+ await expect(removeAgentResourceFiles({ agent_id, files })).rejects.toThrow(error);
+ }
+ });
+ });
+ });
+ });
+
+ describe('Agent CRUD Operations', () => {
+ let mongoServer;
+ let AccessRole;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
+ await mongoose.connect(mongoUri);
+
+ // Initialize models
+ const dbModels = require('~/db/models');
+ AccessRole = dbModels.AccessRole;
+
+ // Create necessary access roles for agents
+ await AccessRole.create({
+ accessRoleId: AccessRoleIds.AGENT_OWNER,
+ name: 'Owner',
+ description: 'Full control over agents',
+ resourceType: ResourceType.AGENT,
+ permBits: 15, // VIEW | EDIT | DELETE | SHARE
+ });
+ }, 20000);
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ await Agent.deleteMany({});
+ });
+
+ test('should create and get an agent', async () => {
+ const { agentId, authorId } = createTestIds();
+
+ const newAgent = await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ description: 'Test description',
+ });
+
+ expect(newAgent).toBeDefined();
+ expect(newAgent.id).toBe(agentId);
+ expect(newAgent.name).toBe('Test Agent');
+
+ const retrievedAgent = await getAgent({ id: agentId });
+ expect(retrievedAgent).toBeDefined();
+ expect(retrievedAgent.id).toBe(agentId);
+ expect(retrievedAgent.name).toBe('Test Agent');
+ expect(retrievedAgent.description).toBe('Test description');
+ });
+
+ test('should delete an agent', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Agent To Delete',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ });
+
+ const agentBeforeDelete = await getAgent({ id: agentId });
+ expect(agentBeforeDelete).toBeDefined();
+
+ await deleteAgent({ id: agentId });
+
+ const agentAfterDelete = await getAgent({ id: agentId });
+ expect(agentAfterDelete).toBeNull();
+ });
+
+ test('should remove ACL entries when deleting an agent', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ // Create agent
+ const agent = await createAgent({
+ id: agentId,
+ name: 'Agent With Permissions',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ });
+
+ // Grant permissions (simulating sharing)
+ await permissionService.grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: authorId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_OWNER,
+ grantedBy: authorId,
+ });
+
+ // Verify ACL entry exists
+ const aclEntriesBefore = await AclEntry.find({
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ });
+ expect(aclEntriesBefore).toHaveLength(1);
+
+ // Delete the agent
+ await deleteAgent({ id: agentId });
+
+ // Verify agent is deleted
+ const agentAfterDelete = await getAgent({ id: agentId });
+ expect(agentAfterDelete).toBeNull();
+
+ // Verify ACL entries are removed
+ const aclEntriesAfter = await AclEntry.find({
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ });
+ expect(aclEntriesAfter).toHaveLength(0);
+ });
+
+ test('should list agents by author', async () => {
+ const authorId = new mongoose.Types.ObjectId();
+ const otherAuthorId = new mongoose.Types.ObjectId();
+
+ const agentIds = [];
+ for (let i = 0; i < 5; i++) {
+ const id = `agent_${uuidv4()}`;
+ agentIds.push(id);
+ await createAgent({
+ id,
+ name: `Agent ${i}`,
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ });
+ }
+
+ for (let i = 0; i < 3; i++) {
+ await createAgent({
+ id: `other_agent_${uuidv4()}`,
+ name: `Other Agent ${i}`,
+ provider: 'test',
+ model: 'test-model',
+ author: otherAuthorId,
+ });
+ }
+
+ const result = await getListAgents({ author: authorId.toString() });
+
+ expect(result).toBeDefined();
+ expect(result.data).toBeDefined();
+ expect(result.data).toHaveLength(5);
+ expect(result.has_more).toBe(true);
+
+ for (const agent of result.data) {
+ expect(agent.author).toBe(authorId.toString());
+ }
+ });
+
+ test('should update agent projects', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+ const projectId1 = new mongoose.Types.ObjectId();
+ const projectId2 = new mongoose.Types.ObjectId();
+ const projectId3 = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Project Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ projectIds: [projectId1],
+ });
+
+ await updateAgent(
+ { id: agentId },
+ { $addToSet: { projectIds: { $each: [projectId2, projectId3] } } },
+ );
+
+ await updateAgent({ id: agentId }, { $pull: { projectIds: projectId1 } });
+
+ await updateAgent({ id: agentId }, { projectIds: [projectId2, projectId3] });
+
+ const updatedAgent = await getAgent({ id: agentId });
+ expect(updatedAgent.projectIds).toHaveLength(2);
+ expect(updatedAgent.projectIds.map((id) => id.toString())).toContain(projectId2.toString());
+ expect(updatedAgent.projectIds.map((id) => id.toString())).toContain(projectId3.toString());
+ expect(updatedAgent.projectIds.map((id) => id.toString())).not.toContain(
+ projectId1.toString(),
+ );
+
+ await updateAgent({ id: agentId }, { projectIds: [] });
+
+ const emptyProjectsAgent = await getAgent({ id: agentId });
+ expect(emptyProjectsAgent.projectIds).toHaveLength(0);
+
+ const nonExistentId = `agent_${uuidv4()}`;
+ await expect(
+ updateAgentProjects({
+ id: nonExistentId,
+ projectIds: [projectId1],
+ }),
+ ).rejects.toThrow();
+ });
+
+ test('should handle ephemeral agent loading', async () => {
+ const agentId = 'ephemeral_test';
+ const endpoint = 'openai';
+
+ const originalModule = jest.requireActual('librechat-data-provider');
+
+ const mockDataProvider = {
+ ...originalModule,
+ Constants: {
+ ...originalModule.Constants,
+ EPHEMERAL_AGENT_ID: 'ephemeral_test',
+ },
+ };
+
+ jest.doMock('librechat-data-provider', () => mockDataProvider);
+
+ expect(agentId).toBeDefined();
+ expect(endpoint).toBeDefined();
+
+ jest.dontMock('librechat-data-provider');
+ });
+
+ test('should handle loadAgent functionality and errors', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Load Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ tools: ['tool1', 'tool2'],
+ });
+
+ const agent = await getAgent({ id: agentId });
+
+ expect(agent).toBeDefined();
+ expect(agent.id).toBe(agentId);
+ expect(agent.name).toBe('Test Load Agent');
+ expect(agent.tools).toEqual(expect.arrayContaining(['tool1', 'tool2']));
+
+ const mockLoadAgent = jest.fn().mockResolvedValue(agent);
+ const loadedAgent = await mockLoadAgent();
+ expect(loadedAgent).toBeDefined();
+ expect(loadedAgent.id).toBe(agentId);
+
+ const nonExistentId = `agent_${uuidv4()}`;
+ const nonExistentAgent = await getAgent({ id: nonExistentId });
+ expect(nonExistentAgent).toBeNull();
+
+ const mockLoadAgentError = jest.fn().mockRejectedValue(new Error('No agent found with ID'));
+ await expect(mockLoadAgentError()).rejects.toThrow('No agent found with ID');
+ });
+
+ describe('Edge Cases', () => {
+ test.each([
+ {
+ name: 'getAgent with undefined search parameters',
+ fn: () => getAgent(undefined),
+ expected: null,
+ },
+ {
+ name: 'deleteAgent with non-existent agent',
+ fn: () => deleteAgent({ id: 'non-existent' }),
+ expected: null,
+ },
+ ])('$name should return null', async ({ fn, expected }) => {
+ const result = await fn();
+ expect(result).toBe(expected);
+ });
+
+ test('should handle getListAgents with invalid author format', async () => {
+ try {
+ const result = await getListAgents({ author: 'invalid-object-id' });
+ expect(result.data).toEqual([]);
+ } catch (error) {
+ expect(error).toBeDefined();
+ }
+ });
+
+ test('should handle getListAgents with no agents', async () => {
+ const authorId = new mongoose.Types.ObjectId();
+ const result = await getListAgents({ author: authorId.toString() });
+
+ expect(result).toBeDefined();
+ expect(result.data).toEqual([]);
+ expect(result.has_more).toBe(false);
+ expect(result.first_id).toBeNull();
+ expect(result.last_id).toBeNull();
+ });
+
+ test('should handle updateAgentProjects with non-existent agent', async () => {
+ const nonExistentId = `agent_${uuidv4()}`;
+ const userId = new mongoose.Types.ObjectId();
+ const projectId = new mongoose.Types.ObjectId();
+
+ const result = await updateAgentProjects({
+ user: { id: userId.toString() },
+ agentId: nonExistentId,
+ projectIds: [projectId.toString()],
+ });
+
+ expect(result).toBeNull();
+ });
+ });
+ });
+
+ describe('Agent Version History', () => {
+ let mongoServer;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
+ await mongoose.connect(mongoUri);
+ }, 20000);
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ await Agent.deleteMany({});
+ });
+
+ test('should create an agent with a single entry in versions array', async () => {
+ const agent = await createBasicAgent();
+
+ expect(agent.versions).toBeDefined();
+ expect(Array.isArray(agent.versions)).toBe(true);
+ expect(agent.versions).toHaveLength(1);
+ expect(agent.versions[0].name).toBe('Test Agent');
+ expect(agent.versions[0].provider).toBe('test');
+ expect(agent.versions[0].model).toBe('test-model');
+ });
+
+ test('should accumulate version history across multiple updates', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const author = new mongoose.Types.ObjectId();
+ await createAgent({
+ id: agentId,
+ name: 'First Name',
+ provider: 'test',
+ model: 'test-model',
+ author,
+ description: 'First description',
+ });
+
+ await updateAgent(
+ { id: agentId },
+ { name: 'Second Name', description: 'Second description' },
+ );
+ await updateAgent({ id: agentId }, { name: 'Third Name', model: 'new-model' });
+ const finalAgent = await updateAgent({ id: agentId }, { description: 'Final description' });
+
+ expect(finalAgent.versions).toBeDefined();
+ expect(Array.isArray(finalAgent.versions)).toBe(true);
+ expect(finalAgent.versions).toHaveLength(4);
+
+ expect(finalAgent.versions[0].name).toBe('First Name');
+ expect(finalAgent.versions[0].description).toBe('First description');
+ expect(finalAgent.versions[0].model).toBe('test-model');
+
+ expect(finalAgent.versions[1].name).toBe('Second Name');
+ expect(finalAgent.versions[1].description).toBe('Second description');
+ expect(finalAgent.versions[1].model).toBe('test-model');
+
+ expect(finalAgent.versions[2].name).toBe('Third Name');
+ expect(finalAgent.versions[2].description).toBe('Second description');
+ expect(finalAgent.versions[2].model).toBe('new-model');
+
+ expect(finalAgent.versions[3].name).toBe('Third Name');
+ expect(finalAgent.versions[3].description).toBe('Final description');
+ expect(finalAgent.versions[3].model).toBe('new-model');
+
+ expect(finalAgent.name).toBe('Third Name');
+ expect(finalAgent.description).toBe('Final description');
+ expect(finalAgent.model).toBe('new-model');
+ });
+
+ test('should not include metadata fields in version history', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: new mongoose.Types.ObjectId(),
+ });
+
+ const updatedAgent = await updateAgent({ id: agentId }, { description: 'New description' });
+
+ expect(updatedAgent.versions).toHaveLength(2);
+ expect(updatedAgent.versions[0]._id).toBeUndefined();
+ expect(updatedAgent.versions[0].__v).toBeUndefined();
+ expect(updatedAgent.versions[0].name).toBe('Test Agent');
+ expect(updatedAgent.versions[0].author).toBeUndefined();
+
+ expect(updatedAgent.versions[1]._id).toBeUndefined();
+ expect(updatedAgent.versions[1].__v).toBeUndefined();
+ });
+
+ test('should not recursively include previous versions', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: new mongoose.Types.ObjectId(),
+ });
+
+ await updateAgent({ id: agentId }, { name: 'Updated Name 1' });
+ await updateAgent({ id: agentId }, { name: 'Updated Name 2' });
+ const finalAgent = await updateAgent({ id: agentId }, { name: 'Updated Name 3' });
+
+ expect(finalAgent.versions).toHaveLength(4);
+
+ finalAgent.versions.forEach((version) => {
+ expect(version.versions).toBeUndefined();
+ });
+ });
+
+ test('should handle MongoDB operators and field updates correctly', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+ const projectId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'MongoDB Operator Test',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ tools: ['tool1'],
+ });
+
+ await updateAgent(
+ { id: agentId },
+ {
+ description: 'Updated description',
+ $push: { tools: 'tool2' },
+ $addToSet: { projectIds: projectId },
+ },
+ );
+
+ const firstUpdate = await getAgent({ id: agentId });
+ expect(firstUpdate.description).toBe('Updated description');
+ expect(firstUpdate.tools).toContain('tool1');
+ expect(firstUpdate.tools).toContain('tool2');
+ expect(firstUpdate.projectIds.map((id) => id.toString())).toContain(projectId.toString());
+ expect(firstUpdate.versions).toHaveLength(2);
+
+ await updateAgent(
+ { id: agentId },
+ {
+ tools: ['tool2', 'tool3'],
+ },
+ );
+
+ const secondUpdate = await getAgent({ id: agentId });
+ expect(secondUpdate.tools).toHaveLength(2);
+ expect(secondUpdate.tools).toContain('tool2');
+ expect(secondUpdate.tools).toContain('tool3');
+ expect(secondUpdate.tools).not.toContain('tool1');
+ expect(secondUpdate.versions).toHaveLength(3);
+
+ await updateAgent(
+ { id: agentId },
+ {
+ $push: { tools: 'tool3' },
+ },
+ );
+
+ const thirdUpdate = await getAgent({ id: agentId });
+ const toolCount = thirdUpdate.tools.filter((t) => t === 'tool3').length;
+ expect(toolCount).toBe(2);
+ expect(thirdUpdate.versions).toHaveLength(4);
+ });
+
+ test('should handle parameter objects correctly', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Parameters Test',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ model_parameters: { temperature: 0.7 },
+ });
+
+ const updatedAgent = await updateAgent(
+ { id: agentId },
+ { model_parameters: { temperature: 0.8 } },
+ );
+
+ expect(updatedAgent.versions).toHaveLength(2);
+ expect(updatedAgent.model_parameters.temperature).toBe(0.8);
+
+ await updateAgent(
+ { id: agentId },
+ {
+ model_parameters: {
+ temperature: 0.8,
+ max_tokens: 1000,
+ },
+ },
+ );
+
+ const complexAgent = await getAgent({ id: agentId });
+ expect(complexAgent.versions).toHaveLength(3);
+ expect(complexAgent.model_parameters.temperature).toBe(0.8);
+ expect(complexAgent.model_parameters.max_tokens).toBe(1000);
+
+ await updateAgent({ id: agentId }, { model_parameters: {} });
+
+ const emptyParamsAgent = await getAgent({ id: agentId });
+ expect(emptyParamsAgent.versions).toHaveLength(4);
+ expect(emptyParamsAgent.model_parameters).toEqual({});
+ });
+
+ test('should not create new version for duplicate updates', async () => {
+ const authorId = new mongoose.Types.ObjectId();
+ const testCases = generateVersionTestCases();
+
+ for (const testCase of testCases) {
+ const testAgentId = `agent_${uuidv4()}`;
+
+ await createAgent({
+ id: testAgentId,
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ ...testCase.initial,
+ });
+
+ const updatedAgent = await updateAgent({ id: testAgentId }, testCase.update);
+ expect(updatedAgent.versions).toHaveLength(2); // No new version created
+
+ // Update with duplicate data should succeed but not create a new version
+ const duplicateUpdate = await updateAgent({ id: testAgentId }, testCase.duplicate);
+
+ expect(duplicateUpdate.versions).toHaveLength(2); // No new version created
+
+ const agent = await getAgent({ id: testAgentId });
+ expect(agent.versions).toHaveLength(2);
+ }
+ });
+
+ test('should track updatedBy when a different user updates an agent', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const originalAuthor = new mongoose.Types.ObjectId();
+ const updatingUser = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Original Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: originalAuthor,
+ description: 'Original description',
+ });
+
+ const updatedAgent = await updateAgent(
+ { id: agentId },
+ { name: 'Updated Agent', description: 'Updated description' },
+ { updatingUserId: updatingUser.toString() },
+ );
+
+ expect(updatedAgent.versions).toHaveLength(2);
+ expect(updatedAgent.versions[1].updatedBy.toString()).toBe(updatingUser.toString());
+ expect(updatedAgent.author.toString()).toBe(originalAuthor.toString());
+ });
+
+ test('should include updatedBy even when the original author updates the agent', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const originalAuthor = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Original Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: originalAuthor,
+ description: 'Original description',
+ });
+
+ const updatedAgent = await updateAgent(
+ { id: agentId },
+ { name: 'Updated Agent', description: 'Updated description' },
+ { updatingUserId: originalAuthor.toString() },
+ );
+
+ expect(updatedAgent.versions).toHaveLength(2);
+ expect(updatedAgent.versions[1].updatedBy.toString()).toBe(originalAuthor.toString());
+ expect(updatedAgent.author.toString()).toBe(originalAuthor.toString());
+ });
+
+ test('should track multiple different users updating the same agent', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const originalAuthor = new mongoose.Types.ObjectId();
+ const user1 = new mongoose.Types.ObjectId();
+ const user2 = new mongoose.Types.ObjectId();
+ const user3 = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Original Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: originalAuthor,
+ description: 'Original description',
+ });
+
+ // User 1 makes an update
+ await updateAgent(
+ { id: agentId },
+ { name: 'Updated by User 1', description: 'First update' },
+ { updatingUserId: user1.toString() },
+ );
+
+ // Original author makes an update
+ await updateAgent(
+ { id: agentId },
+ { description: 'Updated by original author' },
+ { updatingUserId: originalAuthor.toString() },
+ );
+
+ // User 2 makes an update
+ await updateAgent(
+ { id: agentId },
+ { name: 'Updated by User 2', model: 'new-model' },
+ { updatingUserId: user2.toString() },
+ );
+
+ // User 3 makes an update
+ const finalAgent = await updateAgent(
+ { id: agentId },
+ { description: 'Final update by User 3' },
+ { updatingUserId: user3.toString() },
+ );
+
+ expect(finalAgent.versions).toHaveLength(5);
+ expect(finalAgent.author.toString()).toBe(originalAuthor.toString());
+
+ // Check that each version has the correct updatedBy
+ expect(finalAgent.versions[0].updatedBy).toBeUndefined(); // Initial creation has no updatedBy
+ expect(finalAgent.versions[1].updatedBy.toString()).toBe(user1.toString());
+ expect(finalAgent.versions[2].updatedBy.toString()).toBe(originalAuthor.toString());
+ expect(finalAgent.versions[3].updatedBy.toString()).toBe(user2.toString());
+ expect(finalAgent.versions[4].updatedBy.toString()).toBe(user3.toString());
+
+ // Verify the final state
+ expect(finalAgent.name).toBe('Updated by User 2');
+ expect(finalAgent.description).toBe('Final update by User 3');
+ expect(finalAgent.model).toBe('new-model');
+ });
+
+ test('should preserve original author during agent restoration', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const originalAuthor = new mongoose.Types.ObjectId();
+ const updatingUser = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Original Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: originalAuthor,
+ description: 'Original description',
+ });
+
+ await updateAgent(
+ { id: agentId },
+ { name: 'Updated Agent', description: 'Updated description' },
+ { updatingUserId: updatingUser.toString() },
+ );
+
+ const { revertAgentVersion } = require('./Agent');
+ const revertedAgent = await revertAgentVersion({ id: agentId }, 0);
+
+ expect(revertedAgent.author.toString()).toBe(originalAuthor.toString());
+ expect(revertedAgent.name).toBe('Original Agent');
+ expect(revertedAgent.description).toBe('Original description');
+ });
+
+ test('should detect action metadata changes and force version update', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+ const actionId = 'testActionId123';
+
+ // Create agent with actions
+ await createAgent({
+ id: agentId,
+ name: 'Agent with Actions',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ actions: [`test.com_action_${actionId}`],
+ tools: ['listEvents_action_test.com', 'createEvent_action_test.com'],
+ });
+
+ // First update with forceVersion should create a version
+ const firstUpdate = await updateAgent(
+ { id: agentId },
+ { tools: ['listEvents_action_test.com', 'createEvent_action_test.com'] },
+ { updatingUserId: authorId.toString(), forceVersion: true },
+ );
+
+ expect(firstUpdate.versions).toHaveLength(2);
+
+ // Second update with same data but forceVersion should still create a version
+ const secondUpdate = await updateAgent(
+ { id: agentId },
+ { tools: ['listEvents_action_test.com', 'createEvent_action_test.com'] },
+ { updatingUserId: authorId.toString(), forceVersion: true },
+ );
+
+ expect(secondUpdate.versions).toHaveLength(3);
+
+ // Update without forceVersion and no changes should not create a version
+ const duplicateUpdate = await updateAgent(
+ { id: agentId },
+ { tools: ['listEvents_action_test.com', 'createEvent_action_test.com'] },
+ { updatingUserId: authorId.toString(), forceVersion: false },
+ );
+
+ expect(duplicateUpdate.versions).toHaveLength(3); // No new version created
+ });
+
+ test('should handle isDuplicateVersion with arrays containing null/undefined values', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ tools: ['tool1', null, 'tool2', undefined],
+ });
+
+ // Update with same array but different null/undefined arrangement
+ const updatedAgent = await updateAgent({ id: agentId }, { tools: ['tool1', 'tool2'] });
+
+ expect(updatedAgent.versions).toHaveLength(2);
+ expect(updatedAgent.tools).toEqual(['tool1', 'tool2']);
+ });
+
+ test('should handle isDuplicateVersion with empty objects in tool_kwargs', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ tool_kwargs: [
+ { tool: 'tool1', config: { setting: 'value' } },
+ {},
+ { tool: 'tool2', config: {} },
+ ],
+ });
+
+ // Try to update with reordered but equivalent tool_kwargs
+ const updatedAgent = await updateAgent(
+ { id: agentId },
+ {
+ tool_kwargs: [
+ { tool: 'tool2', config: {} },
+ { tool: 'tool1', config: { setting: 'value' } },
+ {},
+ ],
+ },
+ );
+
+ // Should create new version as order matters for arrays
+ expect(updatedAgent.versions).toHaveLength(2);
+ });
+
+ test('should handle isDuplicateVersion with mixed primitive and object arrays', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ mixed_array: [1, 'string', { key: 'value' }, true, null],
+ });
+
+ // Update with same values but different types
+ const updatedAgent = await updateAgent(
+ { id: agentId },
+ { mixed_array: ['1', 'string', { key: 'value' }, 'true', null] },
+ );
+
+ // Should create new version as types differ
+ expect(updatedAgent.versions).toHaveLength(2);
+ });
+
+ test('should handle isDuplicateVersion with deeply nested objects', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ const deepObject = {
+ level1: {
+ level2: {
+ level3: {
+ level4: {
+ value: 'deep',
+ array: [1, 2, { nested: true }],
+ },
+ },
+ },
+ },
+ };
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ model_parameters: deepObject,
+ });
+
+ // First create a version with changes
+ await updateAgent({ id: agentId }, { description: 'Updated' });
+
+ // Then try to create duplicate of the original version
+ await updateAgent(
+ { id: agentId },
+ {
+ model_parameters: deepObject,
+ description: undefined,
+ },
+ );
+
+ // Since we're updating back to the same model_parameters but with a different description,
+ // it should create a new version
+ const agent = await getAgent({ id: agentId });
+ expect(agent.versions).toHaveLength(3);
+ });
+
+ test('should handle version comparison with special field types', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+ const projectId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ projectIds: [projectId],
+ model_parameters: { temperature: 0.7 },
+ });
+
+ // Update with a real field change first
+ const firstUpdate = await updateAgent({ id: agentId }, { description: 'New description' });
+
+ expect(firstUpdate.versions).toHaveLength(2);
+
+ // Update with model parameters change
+ const secondUpdate = await updateAgent(
+ { id: agentId },
+ { model_parameters: { temperature: 0.8 } },
+ );
+
+ expect(secondUpdate.versions).toHaveLength(3);
+ });
+
+ test('should detect changes in support_contact fields', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ // Create agent with initial support_contact
+ await createAgent({
+ id: agentId,
+ name: 'Agent with Support Contact',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ support_contact: {
+ name: 'Initial Support',
+ email: 'initial@support.com',
+ },
+ });
+
+ // Update support_contact name only
+ const firstUpdate = await updateAgent(
+ { id: agentId },
+ {
+ support_contact: {
+ name: 'Updated Support',
+ email: 'initial@support.com',
+ },
+ },
+ );
+
+ expect(firstUpdate.versions).toHaveLength(2);
+ expect(firstUpdate.support_contact.name).toBe('Updated Support');
+ expect(firstUpdate.support_contact.email).toBe('initial@support.com');
+
+ // Update support_contact email only
+ const secondUpdate = await updateAgent(
+ { id: agentId },
+ {
+ support_contact: {
+ name: 'Updated Support',
+ email: 'updated@support.com',
+ },
+ },
+ );
+
+ expect(secondUpdate.versions).toHaveLength(3);
+ expect(secondUpdate.support_contact.email).toBe('updated@support.com');
+
+ // Try to update with same support_contact - should be detected as duplicate but return successfully
+ const duplicateUpdate = await updateAgent(
+ { id: agentId },
+ {
+ support_contact: {
+ name: 'Updated Support',
+ email: 'updated@support.com',
+ },
+ },
+ );
+
+ // Should not create a new version
+ expect(duplicateUpdate.versions).toHaveLength(3);
+ expect(duplicateUpdate.version).toBe(3);
+ expect(duplicateUpdate.support_contact.email).toBe('updated@support.com');
+ });
+
+ test('should handle support_contact from empty to populated', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ // Create agent without support_contact
+ const agent = await createAgent({
+ id: agentId,
+ name: 'Agent without Support',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ });
+
+ // Verify support_contact is undefined since it wasn't provided
+ expect(agent.support_contact).toBeUndefined();
+
+ // Update to add support_contact
+ const updated = await updateAgent(
+ { id: agentId },
+ {
+ support_contact: {
+ name: 'New Support Team',
+ email: 'support@example.com',
+ },
+ },
+ );
+
+ expect(updated.versions).toHaveLength(2);
+ expect(updated.support_contact.name).toBe('New Support Team');
+ expect(updated.support_contact.email).toBe('support@example.com');
+ });
+
+ test('should handle support_contact edge cases in isDuplicateVersion', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ // Create agent with support_contact
+ await createAgent({
+ id: agentId,
+ name: 'Edge Case Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ support_contact: {
+ name: 'Support',
+ email: 'support@test.com',
+ },
+ });
+
+ // Update to empty support_contact
+ const emptyUpdate = await updateAgent(
+ { id: agentId },
+ {
+ support_contact: {},
+ },
+ );
+
+ expect(emptyUpdate.versions).toHaveLength(2);
+ expect(emptyUpdate.support_contact).toEqual({});
+
+ // Update back to populated support_contact
+ const repopulated = await updateAgent(
+ { id: agentId },
+ {
+ support_contact: {
+ name: 'Support',
+ email: 'support@test.com',
+ },
+ },
+ );
+
+ expect(repopulated.versions).toHaveLength(3);
+
+ // Verify all versions have correct support_contact
+ const finalAgent = await getAgent({ id: agentId });
+ expect(finalAgent.versions[0].support_contact).toEqual({
+ name: 'Support',
+ email: 'support@test.com',
+ });
+ expect(finalAgent.versions[1].support_contact).toEqual({});
+ expect(finalAgent.versions[2].support_contact).toEqual({
+ name: 'Support',
+ email: 'support@test.com',
+ });
+ });
+
+ test('should preserve support_contact in version history', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ // Create agent
+ await createAgent({
+ id: agentId,
+ name: 'Version History Test',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ support_contact: {
+ name: 'Initial Contact',
+ email: 'initial@test.com',
+ },
+ });
+
+ // Multiple updates with different support_contact values
+ await updateAgent(
+ { id: agentId },
+ {
+ support_contact: {
+ name: 'Second Contact',
+ email: 'second@test.com',
+ },
+ },
+ );
+
+ await updateAgent(
+ { id: agentId },
+ {
+ support_contact: {
+ name: 'Third Contact',
+ email: 'third@test.com',
+ },
+ },
+ );
+
+ const finalAgent = await getAgent({ id: agentId });
+
+ // Verify version history
+ expect(finalAgent.versions).toHaveLength(3);
+ expect(finalAgent.versions[0].support_contact).toEqual({
+ name: 'Initial Contact',
+ email: 'initial@test.com',
+ });
+ expect(finalAgent.versions[1].support_contact).toEqual({
+ name: 'Second Contact',
+ email: 'second@test.com',
+ });
+ expect(finalAgent.versions[2].support_contact).toEqual({
+ name: 'Third Contact',
+ email: 'third@test.com',
+ });
+
+ // Current state should match last version
+ expect(finalAgent.support_contact).toEqual({
+ name: 'Third Contact',
+ email: 'third@test.com',
+ });
+ });
+
+ test('should handle partial support_contact updates', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ // Create agent with full support_contact
+ await createAgent({
+ id: agentId,
+ name: 'Partial Update Test',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ support_contact: {
+ name: 'Original Name',
+ email: 'original@email.com',
+ },
+ });
+
+ // MongoDB's findOneAndUpdate will replace the entire support_contact object
+ // So we need to verify that partial updates still work correctly
+ const updated = await updateAgent(
+ { id: agentId },
+ {
+ support_contact: {
+ name: 'New Name',
+ email: '', // Empty email
+ },
+ },
+ );
+
+ expect(updated.versions).toHaveLength(2);
+ expect(updated.support_contact.name).toBe('New Name');
+ expect(updated.support_contact.email).toBe('');
+
+ // Verify isDuplicateVersion works with partial changes - should return successfully without creating new version
+ const duplicateUpdate = await updateAgent(
+ { id: agentId },
+ {
+ support_contact: {
+ name: 'New Name',
+ email: '',
+ },
+ },
+ );
+
+ // Should not create a new version since content is the same
+ expect(duplicateUpdate.versions).toHaveLength(2);
+ expect(duplicateUpdate.version).toBe(2);
+ expect(duplicateUpdate.support_contact.name).toBe('New Name');
+ expect(duplicateUpdate.support_contact.email).toBe('');
+ });
+
+ // Edge Cases
+ describe.each([
+ {
+ operation: 'add',
+ name: 'empty file_id',
+ needsAgent: true,
+ params: { tool_resource: 'file_search', file_id: '' },
+ shouldResolve: true,
+ },
+ {
+ operation: 'add',
+ name: 'non-existent agent',
+ needsAgent: false,
+ params: { tool_resource: 'file_search', file_id: 'file123' },
+ shouldResolve: false,
+ error: 'Agent not found for adding resource file',
+ },
+ ])('addAgentResourceFile with $name', ({ needsAgent, params, shouldResolve, error }) => {
+ test(`should ${shouldResolve ? 'resolve' : 'reject'}`, async () => {
+ const agent = needsAgent ? await createBasicAgent() : null;
+ const agent_id = needsAgent ? agent.id : `agent_${uuidv4()}`;
+
+ if (shouldResolve) {
+ await expect(addAgentResourceFile({ agent_id, ...params })).resolves.toBeDefined();
+ } else {
+ await expect(addAgentResourceFile({ agent_id, ...params })).rejects.toThrow(error);
+ }
+ });
+ });
+
+ describe.each([
+ {
+ name: 'empty files array',
+ files: [],
+ needsAgent: true,
+ shouldResolve: true,
+ },
+ {
+ name: 'non-existent tool_resource',
+ files: [{ tool_resource: 'non_existent_tool', file_id: 'file123' }],
+ needsAgent: true,
+ shouldResolve: true,
+ },
+ {
+ name: 'non-existent agent',
+ files: [{ tool_resource: 'file_search', file_id: 'file123' }],
+ needsAgent: false,
+ shouldResolve: false,
+ error: 'Agent not found for removing resource files',
+ },
+ ])('removeAgentResourceFiles with $name', ({ files, needsAgent, shouldResolve, error }) => {
+ test(`should ${shouldResolve ? 'resolve' : 'reject'}`, async () => {
+ const agent = needsAgent ? await createBasicAgent() : null;
+ const agent_id = needsAgent ? agent.id : `agent_${uuidv4()}`;
+
+ if (shouldResolve) {
+ const result = await removeAgentResourceFiles({ agent_id, files });
+ expect(result).toBeDefined();
+ if (agent) {
+ expect(result.id).toBe(agent.id);
+ }
+ } else {
+ await expect(removeAgentResourceFiles({ agent_id, files })).rejects.toThrow(error);
+ }
+ });
+ });
+
+ describe('Edge Cases', () => {
+ test('should handle extremely large version history', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Version Test',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ });
+
+ for (let i = 0; i < 20; i++) {
+ await updateAgent({ id: agentId }, { description: `Version ${i}` });
+ }
+
+ const agent = await getAgent({ id: agentId });
+ expect(agent.versions).toHaveLength(21);
+ expect(agent.description).toBe('Version 19');
+ });
+
+ test('should handle revertAgentVersion with invalid version index', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ });
+
+ await expect(revertAgentVersion({ id: agentId }, 5)).rejects.toThrow('Version 5 not found');
+ });
+
+ test('should handle revertAgentVersion with non-existent agent', async () => {
+ const nonExistentId = `agent_${uuidv4()}`;
+
+ await expect(revertAgentVersion({ id: nonExistentId }, 0)).rejects.toThrow(
+ 'Agent not found',
+ );
+ });
+
+ test('should handle updateAgent with empty update object', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ });
+
+ const updatedAgent = await updateAgent({ id: agentId }, {});
+
+ expect(updatedAgent).toBeDefined();
+ expect(updatedAgent.name).toBe('Test Agent');
+ expect(updatedAgent.versions).toHaveLength(1);
+ });
+ });
+ });
+
+ describe('Action Metadata and Hash Generation', () => {
+ let mongoServer;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
+ await mongoose.connect(mongoUri);
+ }, 20000);
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ await Agent.deleteMany({});
+ });
+
+ test('should generate consistent hash for same action metadata', async () => {
+ const actionIds = ['test.com_action_123', 'example.com_action_456'];
+ const actions = [
+ {
+ action_id: '123',
+ metadata: { version: '1.0', endpoints: ['GET /api/test'], schema: { type: 'object' } },
+ },
+ {
+ action_id: '456',
+ metadata: {
+ version: '2.0',
+ endpoints: ['POST /api/example'],
+ schema: { type: 'string' },
+ },
+ },
+ ];
+
+ const hash1 = await generateActionMetadataHash(actionIds, actions);
+ const hash2 = await generateActionMetadataHash(actionIds, actions);
+
+ expect(hash1).toBe(hash2);
+ expect(typeof hash1).toBe('string');
+ expect(hash1.length).toBe(64); // SHA-256 produces 64 character hex string
+ });
+
+ test('should generate different hashes for different action metadata', async () => {
+ const actionIds = ['test.com_action_123'];
+ const actions1 = [
+ { action_id: '123', metadata: { version: '1.0', endpoints: ['GET /api/test'] } },
+ ];
+ const actions2 = [
+ { action_id: '123', metadata: { version: '2.0', endpoints: ['GET /api/test'] } },
+ ];
+
+ const hash1 = await generateActionMetadataHash(actionIds, actions1);
+ const hash2 = await generateActionMetadataHash(actionIds, actions2);
+
+ expect(hash1).not.toBe(hash2);
+ });
+
+ test('should handle empty action arrays', async () => {
+ const hash = await generateActionMetadataHash([], []);
+ expect(hash).toBe('');
+ });
+
+ test('should handle null or undefined action arrays', async () => {
+ const hash1 = await generateActionMetadataHash(null, []);
+ const hash2 = await generateActionMetadataHash(undefined, []);
+
+ expect(hash1).toBe('');
+ expect(hash2).toBe('');
+ });
+
+ test('should handle missing action metadata gracefully', async () => {
+ const actionIds = ['test.com_action_123', 'missing.com_action_999'];
+ const actions = [
+ { action_id: '123', metadata: { version: '1.0' } },
+ // missing action with id '999'
+ ];
+
+ const hash = await generateActionMetadataHash(actionIds, actions);
+ expect(typeof hash).toBe('string');
+ expect(hash.length).toBe(64);
+ });
+
+ test('should sort action IDs for consistent hashing', async () => {
+ const actionIds1 = ['b.com_action_2', 'a.com_action_1'];
+ const actionIds2 = ['a.com_action_1', 'b.com_action_2'];
+ const actions = [
+ { action_id: '1', metadata: { version: '1.0' } },
+ { action_id: '2', metadata: { version: '2.0' } },
+ ];
+
+ const hash1 = await generateActionMetadataHash(actionIds1, actions);
+ const hash2 = await generateActionMetadataHash(actionIds2, actions);
+
+ expect(hash1).toBe(hash2);
+ });
+
+ test('should handle complex nested metadata objects', async () => {
+ const actionIds = ['complex.com_action_1'];
+ const actions = [
+ {
+ action_id: '1',
+ metadata: {
+ version: '1.0',
+ schema: {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ nested: {
+ type: 'object',
+ properties: {
+ id: { type: 'number' },
+ tags: { type: 'array', items: { type: 'string' } },
+ },
+ },
+ },
+ },
+ endpoints: [
+ { path: '/api/test', method: 'GET', params: ['id'] },
+ { path: '/api/create', method: 'POST', body: true },
+ ],
+ },
+ },
+ ];
+
+ const hash = await generateActionMetadataHash(actionIds, actions);
+ expect(typeof hash).toBe('string');
+ expect(hash.length).toBe(64);
+ });
+
+ describe('Edge Cases', () => {
+ test('should handle generateActionMetadataHash with null metadata', async () => {
+ const hash = await generateActionMetadataHash(
+ ['test.com_action_1'],
+ [{ action_id: '1', metadata: null }],
+ );
+ expect(typeof hash).toBe('string');
+ });
+
+ test('should handle generateActionMetadataHash with deeply nested metadata', async () => {
+ const deepMetadata = {
+ level1: {
+ level2: {
+ level3: {
+ level4: {
+ level5: 'deep value',
+ array: [1, 2, { nested: true }],
+ },
+ },
+ },
+ },
+ };
+
+ const hash = await generateActionMetadataHash(
+ ['test.com_action_1'],
+ [{ action_id: '1', metadata: deepMetadata }],
+ );
+
+ expect(typeof hash).toBe('string');
+ expect(hash.length).toBe(64);
+ });
+
+ test('should handle generateActionMetadataHash with special characters', async () => {
+ const specialMetadata = {
+ unicode: '🚀🎉👍',
+ symbols: '!@#$%^&*()_+-=[]{}|;:,.<>?',
+ quotes: 'single\'s and "doubles"',
+ newlines: 'line1\nline2\r\nline3',
+ };
+
+ const hash = await generateActionMetadataHash(
+ ['test.com_action_1'],
+ [{ action_id: '1', metadata: specialMetadata }],
+ );
+
+ expect(typeof hash).toBe('string');
+ expect(hash.length).toBe(64);
+ });
+ });
+ });
+
+ describe('Load Agent Functionality', () => {
+ let mongoServer;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
+ await mongoose.connect(mongoUri);
+ }, 20000);
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ await Agent.deleteMany({});
+ });
+
+ test('should return null when agent_id is not provided', async () => {
+ const mockReq = { user: { id: 'user123' } };
+ const result = await loadAgent({
+ req: mockReq,
+ agent_id: null,
+ endpoint: 'openai',
+ model_parameters: { model: 'gpt-4' },
+ });
+
+ expect(result).toBeNull();
+ });
+
+ test('should return null when agent_id is empty string', async () => {
+ const mockReq = { user: { id: 'user123' } };
+ const result = await loadAgent({
+ req: mockReq,
+ agent_id: '',
+ endpoint: 'openai',
+ model_parameters: { model: 'gpt-4' },
+ });
+
+ expect(result).toBeNull();
+ });
+
+ test('should test ephemeral agent loading logic', async () => {
+ const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
+
+ getCachedTools.mockResolvedValue({
+ tool1_mcp_server1: {},
+ tool2_mcp_server2: {},
+ another_tool: {},
+ });
+
+ // Mock getMCPServerTools to return tools for each server
+ getMCPServerTools.mockImplementation(async (_userId, server) => {
+ if (server === 'server1') {
+ return { tool1_mcp_server1: {} };
+ } else if (server === 'server2') {
+ return { tool2_mcp_server2: {} };
+ }
+ return null;
+ });
+
+ const mockReq = {
+ user: { id: 'user123' },
+ body: {
+ promptPrefix: 'Test instructions',
+ ephemeralAgent: {
+ execute_code: true,
+ web_search: true,
+ mcp: ['server1', 'server2'],
+ },
+ },
+ };
+
+ const result = await loadAgent({
+ req: mockReq,
+ agent_id: EPHEMERAL_AGENT_ID,
+ endpoint: 'openai',
+ model_parameters: { model: 'gpt-4', temperature: 0.7 },
+ });
+
+ if (result) {
+ expect(result.id).toBe(EPHEMERAL_AGENT_ID);
+ expect(result.instructions).toBe('Test instructions');
+ expect(result.provider).toBe('openai');
+ expect(result.model).toBe('gpt-4');
+ expect(result.model_parameters.temperature).toBe(0.7);
+ expect(result.tools).toContain('execute_code');
+ expect(result.tools).toContain('web_search');
+ expect(result.tools).toContain('tool1_mcp_server1');
+ expect(result.tools).toContain('tool2_mcp_server2');
+ } else {
+ expect(result).toBeNull();
+ }
+ });
+
+ test('should return null for non-existent agent', async () => {
+ const mockReq = { user: { id: 'user123' } };
+ const result = await loadAgent({
+ req: mockReq,
+ agent_id: 'non_existent_agent',
+ endpoint: 'openai',
+ model_parameters: { model: 'gpt-4' },
+ });
+
+ expect(result).toBeNull();
+ });
+
+ test('should load agent when user is the author', async () => {
+ const userId = new mongoose.Types.ObjectId();
+ const agentId = `agent_${uuidv4()}`;
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userId,
+ description: 'Test description',
+ tools: ['web_search'],
+ });
+
+ const mockReq = { user: { id: userId.toString() } };
+ const result = await loadAgent({
+ req: mockReq,
+ agent_id: agentId,
+ endpoint: 'openai',
+ model_parameters: { model: 'gpt-4' },
+ });
+
+ expect(result).toBeDefined();
+ expect(result.id).toBe(agentId);
+ expect(result.name).toBe('Test Agent');
+ expect(result.author.toString()).toBe(userId.toString());
+ expect(result.version).toBe(1);
+ });
+
+ test('should return agent even when user is not author (permissions checked at route level)', async () => {
+ const authorId = new mongoose.Types.ObjectId();
+ const userId = new mongoose.Types.ObjectId();
+ const agentId = `agent_${uuidv4()}`;
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ });
+
+ const mockReq = { user: { id: userId.toString() } };
+ const result = await loadAgent({
+ req: mockReq,
+ agent_id: agentId,
+ endpoint: 'openai',
+ model_parameters: { model: 'gpt-4' },
+ });
+
+ // With the new permission system, loadAgent returns the agent regardless of permissions
+ // Permission checks are handled at the route level via middleware
+ expect(result).toBeTruthy();
+ expect(result.id).toBe(agentId);
+ expect(result.name).toBe('Test Agent');
+ });
+
+ test('should handle ephemeral agent with no MCP servers', async () => {
+ const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
+
+ getCachedTools.mockResolvedValue({});
+
+ const mockReq = {
+ user: { id: 'user123' },
+ body: {
+ promptPrefix: 'Simple instructions',
+ ephemeralAgent: {
+ execute_code: false,
+ web_search: false,
+ mcp: [],
+ },
+ },
+ };
+
+ const result = await loadAgent({
+ req: mockReq,
+ agent_id: EPHEMERAL_AGENT_ID,
+ endpoint: 'openai',
+ model_parameters: { model: 'gpt-3.5-turbo' },
+ });
+
+ if (result) {
+ expect(result.tools).toEqual([]);
+ expect(result.instructions).toBe('Simple instructions');
+ } else {
+ expect(result).toBeFalsy();
+ }
+ });
+
+ test('should handle ephemeral agent with undefined ephemeralAgent in body', async () => {
+ const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
+
+ getCachedTools.mockResolvedValue({});
+
+ const mockReq = {
+ user: { id: 'user123' },
+ body: {
+ promptPrefix: 'Basic instructions',
+ },
+ };
+
+ const result = await loadAgent({
+ req: mockReq,
+ agent_id: EPHEMERAL_AGENT_ID,
+ endpoint: 'openai',
+ model_parameters: { model: 'gpt-4' },
+ });
+
+ if (result) {
+ expect(result.tools).toEqual([]);
+ } else {
+ expect(result).toBeFalsy();
+ }
+ });
+
+ describe('Edge Cases', () => {
+ test('should handle loadAgent with malformed req object', async () => {
+ const result = await loadAgent({
+ req: null,
+ agent_id: 'test',
+ endpoint: 'openai',
+ model_parameters: { model: 'gpt-4' },
+ });
+
+ expect(result).toBeNull();
+ });
+
+ test('should handle ephemeral agent with extremely large tool list', async () => {
+ const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
+
+ const largeToolList = Array.from({ length: 100 }, (_, i) => `tool_${i}_mcp_server1`);
+ const availableTools = largeToolList.reduce((acc, tool) => {
+ acc[tool] = {};
+ return acc;
+ }, {});
+
+ getCachedTools.mockResolvedValue(availableTools);
+
+ // Mock getMCPServerTools to return all tools for server1
+ getMCPServerTools.mockImplementation(async (_userId, server) => {
+ if (server === 'server1') {
+ return availableTools; // All 100 tools belong to server1
+ }
+ return null;
+ });
+
+ const mockReq = {
+ user: { id: 'user123' },
+ body: {
+ promptPrefix: 'Test',
+ ephemeralAgent: {
+ execute_code: true,
+ web_search: true,
+ mcp: ['server1'],
+ },
+ },
+ };
+
+ const result = await loadAgent({
+ req: mockReq,
+ agent_id: EPHEMERAL_AGENT_ID,
+ endpoint: 'openai',
+ model_parameters: { model: 'gpt-4' },
+ });
+
+ if (result) {
+ expect(result.tools.length).toBeGreaterThan(100);
+ }
+ });
+
+ test('should return agent from different project (permissions checked at route level)', async () => {
+ const authorId = new mongoose.Types.ObjectId();
+ const userId = new mongoose.Types.ObjectId();
+ const agentId = `agent_${uuidv4()}`;
+ const projectId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Project Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ projectIds: [projectId],
+ });
+
+ const mockReq = { user: { id: userId.toString() } };
+ const result = await loadAgent({
+ req: mockReq,
+ agent_id: agentId,
+ endpoint: 'openai',
+ model_parameters: { model: 'gpt-4' },
+ });
+
+ // With the new permission system, loadAgent returns the agent regardless of permissions
+ // Permission checks are handled at the route level via middleware
+ expect(result).toBeTruthy();
+ expect(result.id).toBe(agentId);
+ expect(result.name).toBe('Project Agent');
+ });
+ });
+ });
+
+ describe('Agent Edge Cases and Error Handling', () => {
+ let mongoServer;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
+ await mongoose.connect(mongoUri);
+ }, 20000);
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ await Agent.deleteMany({});
+ });
+
+ test('should handle agent creation with minimal required fields', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ const agent = await createAgent({
+ id: agentId,
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ });
+
+ expect(agent).toBeDefined();
+ expect(agent.id).toBe(agentId);
+ expect(agent.versions).toHaveLength(1);
+ expect(agent.versions[0].provider).toBe('test');
+ expect(agent.versions[0].model).toBe('test-model');
+ });
+
+ test('should handle agent creation with all optional fields', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+ const projectId = new mongoose.Types.ObjectId();
+
+ const agent = await createAgent({
+ id: agentId,
+ name: 'Complex Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ description: 'Complex description',
+ instructions: 'Complex instructions',
+ tools: ['tool1', 'tool2'],
+ actions: ['action1', 'action2'],
+ model_parameters: { temperature: 0.8, max_tokens: 1000 },
+ projectIds: [projectId],
+ avatar: 'https://example.com/avatar.png',
+ isCollaborative: true,
+ tool_resources: {
+ file_search: { file_ids: ['file1', 'file2'] },
+ },
+ });
+
+ expect(agent).toBeDefined();
+ expect(agent.name).toBe('Complex Agent');
+ expect(agent.description).toBe('Complex description');
+ expect(agent.instructions).toBe('Complex instructions');
+ expect(agent.tools).toEqual(['tool1', 'tool2']);
+ expect(agent.actions).toEqual(['action1', 'action2']);
+ expect(agent.model_parameters.temperature).toBe(0.8);
+ expect(agent.model_parameters.max_tokens).toBe(1000);
+ expect(agent.projectIds.map((id) => id.toString())).toContain(projectId.toString());
+ expect(agent.avatar).toBe('https://example.com/avatar.png');
+ expect(agent.isCollaborative).toBe(true);
+ expect(agent.tool_resources.file_search.file_ids).toEqual(['file1', 'file2']);
+ });
+
+ test('should handle updateAgent with empty update object', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ });
+
+ const updatedAgent = await updateAgent({ id: agentId }, {});
+
+ expect(updatedAgent).toBeDefined();
+ expect(updatedAgent.name).toBe('Test Agent');
+ expect(updatedAgent.versions).toHaveLength(1); // No new version should be created
+ });
+
+ test('should handle concurrent updates to different agents', async () => {
+ const agent1Id = `agent_${uuidv4()}`;
+ const agent2Id = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agent1Id,
+ name: 'Agent 1',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ });
+
+ await createAgent({
+ id: agent2Id,
+ name: 'Agent 2',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ });
+
+ // Concurrent updates to different agents
+ const [updated1, updated2] = await Promise.all([
+ updateAgent({ id: agent1Id }, { description: 'Updated Agent 1' }),
+ updateAgent({ id: agent2Id }, { description: 'Updated Agent 2' }),
+ ]);
+
+ expect(updated1.description).toBe('Updated Agent 1');
+ expect(updated2.description).toBe('Updated Agent 2');
+ expect(updated1.versions).toHaveLength(2);
+ expect(updated2.versions).toHaveLength(2);
+ });
+
+ test('should handle agent deletion with non-existent ID', async () => {
+ const nonExistentId = `agent_${uuidv4()}`;
+ const result = await deleteAgent({ id: nonExistentId });
+
+ expect(result).toBeNull();
+ });
+
+ test('should handle getListAgents with no agents', async () => {
+ const authorId = new mongoose.Types.ObjectId();
+ const result = await getListAgents({ author: authorId.toString() });
+
+ expect(result).toBeDefined();
+ expect(result.data).toEqual([]);
+ expect(result.has_more).toBe(false);
+ expect(result.first_id).toBeNull();
+ expect(result.last_id).toBeNull();
+ });
+
+ test('should handle updateAgent with MongoDB operators mixed with direct updates', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ tools: ['tool1'],
+ });
+
+ // Test with $push and direct field update
+ const updatedAgent = await updateAgent(
+ { id: agentId },
+ {
+ name: 'Updated Name',
+ $push: { tools: 'tool2' },
+ },
+ );
+
+ expect(updatedAgent.name).toBe('Updated Name');
+ expect(updatedAgent.tools).toContain('tool1');
+ expect(updatedAgent.tools).toContain('tool2');
+ expect(updatedAgent.versions).toHaveLength(2);
+ });
+
+ test('should handle revertAgentVersion with invalid version index', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ });
+
+ // Try to revert to non-existent version
+ await expect(revertAgentVersion({ id: agentId }, 5)).rejects.toThrow('Version 5 not found');
+ });
+
+ test('should handle revertAgentVersion with non-existent agent', async () => {
+ const nonExistentId = `agent_${uuidv4()}`;
+
+ await expect(revertAgentVersion({ id: nonExistentId }, 0)).rejects.toThrow('Agent not found');
+ });
+
+ test('should handle addAgentResourceFile with non-existent agent', async () => {
+ const nonExistentId = `agent_${uuidv4()}`;
+ const mockReq = { user: { id: 'user123' } };
+
+ await expect(
+ addAgentResourceFile({
+ req: mockReq,
+ agent_id: nonExistentId,
+ tool_resource: 'file_search',
+ file_id: 'file123',
+ }),
+ ).rejects.toThrow('Agent not found for adding resource file');
+ });
+
+ test('should handle removeAgentResourceFiles with non-existent agent', async () => {
+ const nonExistentId = `agent_${uuidv4()}`;
+
+ await expect(
+ removeAgentResourceFiles({
+ agent_id: nonExistentId,
+ files: [{ tool_resource: 'file_search', file_id: 'file123' }],
+ }),
+ ).rejects.toThrow('Agent not found for removing resource files');
+ });
+
+ test('should handle updateAgent with complex nested updates', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ model_parameters: { temperature: 0.5 },
+ tools: ['tool1'],
+ });
+
+ // First update with $push operation
+ const firstUpdate = await updateAgent(
+ { id: agentId },
+ {
+ $push: { tools: 'tool2' },
+ },
+ );
+
+ expect(firstUpdate.tools).toContain('tool1');
+ expect(firstUpdate.tools).toContain('tool2');
+
+ // Second update with direct field update and $addToSet
+ const secondUpdate = await updateAgent(
+ { id: agentId },
+ {
+ name: 'Updated Agent',
+ model_parameters: { temperature: 0.8, max_tokens: 500 },
+ $addToSet: { tools: 'tool3' },
+ },
+ );
+
+ expect(secondUpdate.name).toBe('Updated Agent');
+ expect(secondUpdate.model_parameters.temperature).toBe(0.8);
+ expect(secondUpdate.model_parameters.max_tokens).toBe(500);
+ expect(secondUpdate.tools).toContain('tool1');
+ expect(secondUpdate.tools).toContain('tool2');
+ expect(secondUpdate.tools).toContain('tool3');
+ expect(secondUpdate.versions).toHaveLength(3);
+ });
+
+ test('should preserve version order in versions array', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Version 1',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ });
+
+ await updateAgent({ id: agentId }, { name: 'Version 2' });
+ await updateAgent({ id: agentId }, { name: 'Version 3' });
+ const finalAgent = await updateAgent({ id: agentId }, { name: 'Version 4' });
+
+ expect(finalAgent.versions).toHaveLength(4);
+ expect(finalAgent.versions[0].name).toBe('Version 1');
+ expect(finalAgent.versions[1].name).toBe('Version 2');
+ expect(finalAgent.versions[2].name).toBe('Version 3');
+ expect(finalAgent.versions[3].name).toBe('Version 4');
+ expect(finalAgent.name).toBe('Version 4');
+ });
+
+ test('should handle updateAgentProjects error scenarios', async () => {
+ const nonExistentId = `agent_${uuidv4()}`;
+ const userId = new mongoose.Types.ObjectId();
+ const projectId = new mongoose.Types.ObjectId();
+
+ // Test with non-existent agent
+ const result = await updateAgentProjects({
+ user: { id: userId.toString() },
+ agentId: nonExistentId,
+ projectIds: [projectId.toString()],
+ });
+
+ expect(result).toBeNull();
+ });
+
+ test('should handle revertAgentVersion properly', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Original Name',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ description: 'Original description',
+ });
+
+ await updateAgent(
+ { id: agentId },
+ { name: 'Updated Name', description: 'Updated description' },
+ );
+
+ const revertedAgent = await revertAgentVersion({ id: agentId }, 0);
+
+ expect(revertedAgent.name).toBe('Original Name');
+ expect(revertedAgent.description).toBe('Original description');
+ expect(revertedAgent.author.toString()).toBe(authorId.toString());
+ });
+
+ test('should handle action-related updates with getActions error', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ // Create agent with actions that might cause getActions to fail
+ await createAgent({
+ id: agentId,
+ name: 'Agent with Actions',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ actions: ['test.com_action_invalid_id'],
+ });
+
+ // Update should still work even if getActions fails
+ const updatedAgent = await updateAgent(
+ { id: agentId },
+ { description: 'Updated description' },
+ );
+
+ expect(updatedAgent).toBeDefined();
+ expect(updatedAgent.description).toBe('Updated description');
+ expect(updatedAgent.versions).toHaveLength(2);
+ });
+
+ test('should handle updateAgent with combined MongoDB operators', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+ const projectId1 = new mongoose.Types.ObjectId();
+ const projectId2 = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ tools: ['tool1'],
+ projectIds: [projectId1],
+ });
+
+ // Use multiple operators in single update - but avoid conflicting operations on same field
+ const updatedAgent = await updateAgent(
+ { id: agentId },
+ {
+ name: 'Updated Name',
+ $push: { tools: 'tool2' },
+ $addToSet: { projectIds: projectId2 },
+ },
+ );
+
+ const finalAgent = await updateAgent(
+ { id: agentId },
+ {
+ $pull: { projectIds: projectId1 },
+ },
+ );
+
+ expect(updatedAgent).toBeDefined();
+ expect(updatedAgent.name).toBe('Updated Name');
+ expect(updatedAgent.tools).toContain('tool1');
+ expect(updatedAgent.tools).toContain('tool2');
+ expect(updatedAgent.projectIds.map((id) => id.toString())).toContain(projectId2.toString());
+
+ expect(finalAgent).toBeDefined();
+ expect(finalAgent.projectIds.map((id) => id.toString())).not.toContain(projectId1.toString());
+ expect(finalAgent.versions).toHaveLength(3);
+ });
+
+ test('should handle updateAgent when agent does not exist', async () => {
+ const nonExistentId = `agent_${uuidv4()}`;
+
+ const result = await updateAgent({ id: nonExistentId }, { name: 'New Name' });
+
+ expect(result).toBeNull();
+ });
+
+ test('should handle concurrent updates with database errors', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ });
+
+ // Mock findOneAndUpdate to simulate database error
+ const cleanup = mockFindOneAndUpdateError(2);
+
+ // Concurrent updates where one fails
+ const promises = [
+ updateAgent({ id: agentId }, { name: 'Update 1' }),
+ updateAgent({ id: agentId }, { name: 'Update 2' }),
+ updateAgent({ id: agentId }, { name: 'Update 3' }),
+ ];
+
+ const results = await Promise.allSettled(promises);
+
+ cleanup();
+
+ const succeeded = results.filter((r) => r.status === 'fulfilled').length;
+ const failed = results.filter((r) => r.status === 'rejected').length;
+
+ expect(succeeded).toBe(2);
+ expect(failed).toBe(1);
+ });
+
+ test('should handle removeAgentResourceFiles when agent is deleted during operation', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: ['file1', 'file2', 'file3'],
+ },
+ },
+ });
+
+ // Mock findOneAndUpdate to return null (simulating deletion)
+ const originalFindOneAndUpdate = Agent.findOneAndUpdate;
+ Agent.findOneAndUpdate = jest.fn().mockImplementation(() => ({
+ lean: jest.fn().mockResolvedValue(null),
+ }));
+
+ // Try to remove files from deleted agent
+ await expect(
+ removeAgentResourceFiles({
+ agent_id: agentId,
+ files: [
+ { tool_resource: 'file_search', file_id: 'file1' },
+ { tool_resource: 'file_search', file_id: 'file2' },
+ ],
+ }),
+ ).rejects.toThrow('Failed to update agent during file removal (pull step)');
+
+ Agent.findOneAndUpdate = originalFindOneAndUpdate;
+ });
+
+ test('should handle loadEphemeralAgent with malformed MCP tool names', async () => {
+ const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
+
+ getCachedTools.mockResolvedValue({
+ malformed_tool_name: {}, // No mcp delimiter
+ tool__server1: {}, // Wrong delimiter
+ tool_mcp_server1: {}, // Correct format
+ tool_mcp_server2: {}, // Different server
+ });
+
+ // Mock getMCPServerTools to return only tools matching the server
+ getMCPServerTools.mockImplementation(async (_userId, server) => {
+ if (server === 'server1') {
+ // Only return tool that correctly matches server1 format
+ return { tool_mcp_server1: {} };
+ } else if (server === 'server2') {
+ return { tool_mcp_server2: {} };
+ }
+ return null;
+ });
+
+ const mockReq = {
+ user: { id: 'user123' },
+ body: {
+ promptPrefix: 'Test instructions',
+ ephemeralAgent: {
+ execute_code: false,
+ web_search: false,
+ mcp: ['server1'],
+ },
+ },
+ };
+
+ const result = await loadAgent({
+ req: mockReq,
+ agent_id: EPHEMERAL_AGENT_ID,
+ endpoint: 'openai',
+ model_parameters: { model: 'gpt-4' },
+ });
+
+ if (result) {
+ expect(result.tools).toEqual(['tool_mcp_server1']);
+ expect(result.tools).not.toContain('malformed_tool_name');
+ expect(result.tools).not.toContain('tool__server1');
+ expect(result.tools).not.toContain('tool_mcp_server2');
+ }
+ });
+
+ test('should handle addAgentResourceFile when array initialization fails', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ });
+
+ // Mock the updateOne operation to fail but let updateAgent succeed
+ const originalUpdateOne = Agent.updateOne;
+ let updateOneCalled = false;
+ Agent.updateOne = jest.fn().mockImplementation((...args) => {
+ if (!updateOneCalled) {
+ updateOneCalled = true;
+ return Promise.reject(new Error('Database error'));
+ }
+ return originalUpdateOne.apply(Agent, args);
+ });
+
+ try {
+ const result = await addAgentResourceFile({
+ agent_id: agentId,
+ tool_resource: 'new_tool',
+ file_id: 'file123',
+ });
+
+ expect(result).toBeDefined();
+ expect(result.tools).toContain('new_tool');
+ } catch (error) {
+ expect(error.message).toBe('Database error');
+ }
+
+ Agent.updateOne = originalUpdateOne;
+ });
+ });
+
+ describe('Agent IDs Field in Version Detection', () => {
+ let mongoServer;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
+ await mongoose.connect(mongoUri);
+ }, 20000);
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ await Agent.deleteMany({});
+ });
+
+ test('should now create new version when agent_ids field changes', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ const agent = await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ agent_ids: ['agent1', 'agent2'],
+ });
+
+ expect(agent).toBeDefined();
+ expect(agent.versions).toHaveLength(1);
+
+ const updated = await updateAgent(
+ { id: agentId },
+ { agent_ids: ['agent1', 'agent2', 'agent3'] },
+ );
+
+ // Since agent_ids is no longer excluded, this should create a new version
+ expect(updated.versions).toHaveLength(2);
+ expect(updated.agent_ids).toEqual(['agent1', 'agent2', 'agent3']);
+ });
+
+ test('should detect duplicate version if agent_ids is updated to same value', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ agent_ids: ['agent1', 'agent2'],
+ });
+
+ const updatedAgent = await updateAgent(
+ { id: agentId },
+ { agent_ids: ['agent1', 'agent2', 'agent3'] },
+ );
+ expect(updatedAgent.versions).toHaveLength(2);
+
+ // Update with same agent_ids should succeed but not create a new version
+ const duplicateUpdate = await updateAgent(
+ { id: agentId },
+ { agent_ids: ['agent1', 'agent2', 'agent3'] },
+ );
+ expect(duplicateUpdate.versions).toHaveLength(2); // No new version created
+ });
+
+ test('should handle agent_ids field alongside other fields', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ description: 'Initial description',
+ agent_ids: ['agent1'],
+ });
+
+ const updated = await updateAgent(
+ { id: agentId },
+ {
+ agent_ids: ['agent1', 'agent2'],
+ description: 'Updated description',
+ },
+ );
+
+ expect(updated.versions).toHaveLength(2);
+ expect(updated.agent_ids).toEqual(['agent1', 'agent2']);
+ expect(updated.description).toBe('Updated description');
+
+ const updated2 = await updateAgent({ id: agentId }, { description: 'Another description' });
+
+ expect(updated2.versions).toHaveLength(3);
+ expect(updated2.agent_ids).toEqual(['agent1', 'agent2']);
+ expect(updated2.description).toBe('Another description');
+ });
+
+ test('should skip version creation when skipVersioning option is used', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+ const projectId1 = new mongoose.Types.ObjectId();
+ const projectId2 = new mongoose.Types.ObjectId();
+
+ // Create agent with initial projectIds
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ projectIds: [projectId1],
+ });
+
+ // Share agent using updateAgentProjects (which uses skipVersioning)
+ const shared = await updateAgentProjects({
+ user: { id: authorId.toString() }, // Use the same author ID
+ agentId: agentId,
+ projectIds: [projectId2.toString()],
+ });
+
+ // Should NOT create a new version due to skipVersioning
+ expect(shared.versions).toHaveLength(1);
+ expect(shared.projectIds.map((id) => id.toString())).toContain(projectId1.toString());
+ expect(shared.projectIds.map((id) => id.toString())).toContain(projectId2.toString());
+
+ // Unshare agent using updateAgentProjects
+ const unshared = await updateAgentProjects({
+ user: { id: authorId.toString() },
+ agentId: agentId,
+ removeProjectIds: [projectId1.toString()],
+ });
+
+ // Still should NOT create a new version
+ expect(unshared.versions).toHaveLength(1);
+ expect(unshared.projectIds.map((id) => id.toString())).not.toContain(projectId1.toString());
+ expect(unshared.projectIds.map((id) => id.toString())).toContain(projectId2.toString());
+
+ // Regular update without skipVersioning should create a version
+ const regularUpdate = await updateAgent(
+ { id: agentId },
+ { description: 'Updated description' },
+ );
+
+ expect(regularUpdate.versions).toHaveLength(2);
+ expect(regularUpdate.description).toBe('Updated description');
+
+ // Direct updateAgent with MongoDB operators should still create versions
+ const directUpdate = await updateAgent(
+ { id: agentId },
+ { $addToSet: { projectIds: { $each: [projectId1] } } },
+ );
+
+ expect(directUpdate.versions).toHaveLength(3);
+ expect(directUpdate.projectIds.length).toBe(2);
+ });
+
+ test('should preserve agent_ids in version history', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ agent_ids: ['agent1'],
+ });
+
+ await updateAgent({ id: agentId }, { agent_ids: ['agent1', 'agent2'] });
+
+ await updateAgent({ id: agentId }, { agent_ids: ['agent3'] });
+
+ const finalAgent = await getAgent({ id: agentId });
+
+ expect(finalAgent.versions).toHaveLength(3);
+ expect(finalAgent.versions[0].agent_ids).toEqual(['agent1']);
+ expect(finalAgent.versions[1].agent_ids).toEqual(['agent1', 'agent2']);
+ expect(finalAgent.versions[2].agent_ids).toEqual(['agent3']);
+ expect(finalAgent.agent_ids).toEqual(['agent3']);
+ });
+
+ test('should handle empty agent_ids arrays', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ agent_ids: ['agent1', 'agent2'],
+ });
+
+ const updated = await updateAgent({ id: agentId }, { agent_ids: [] });
+
+ expect(updated.versions).toHaveLength(2);
+ expect(updated.agent_ids).toEqual([]);
+
+ // Update with same empty agent_ids should succeed but not create a new version
+ const duplicateUpdate = await updateAgent({ id: agentId }, { agent_ids: [] });
+ expect(duplicateUpdate.versions).toHaveLength(2); // No new version created
+ expect(duplicateUpdate.agent_ids).toEqual([]);
+ });
+
+ test('should handle agent without agent_ids field', async () => {
+ const agentId = `agent_${uuidv4()}`;
+ const authorId = new mongoose.Types.ObjectId();
+
+ const agent = await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ });
+
+ expect(agent.agent_ids).toEqual([]);
+
+ const updated = await updateAgent({ id: agentId }, { agent_ids: ['agent1'] });
+
+ expect(updated.versions).toHaveLength(2);
+ expect(updated.agent_ids).toEqual(['agent1']);
+ });
+ });
+});
+
+describe('Support Contact Field', () => {
+ let mongoServer;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
+ await mongoose.connect(mongoUri);
+ }, 20000);
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ await Agent.deleteMany({});
+ });
+
+ it('should not create subdocument with ObjectId for support_contact', async () => {
+ const userId = new mongoose.Types.ObjectId();
+ const agentData = {
+ id: 'agent_test_support',
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userId,
+ support_contact: {
+ name: 'Support Team',
+ email: 'support@example.com',
+ },
+ };
+
+ // Create agent
+ const agent = await createAgent(agentData);
+
+ // Verify support_contact is stored correctly
+ expect(agent.support_contact).toBeDefined();
+ expect(agent.support_contact.name).toBe('Support Team');
+ expect(agent.support_contact.email).toBe('support@example.com');
+
+ // Verify no _id field is created in support_contact
+ expect(agent.support_contact._id).toBeUndefined();
+
+ // Fetch from database to double-check
+ const dbAgent = await Agent.findOne({ id: agentData.id });
+ expect(dbAgent.support_contact).toBeDefined();
+ expect(dbAgent.support_contact.name).toBe('Support Team');
+ expect(dbAgent.support_contact.email).toBe('support@example.com');
+ expect(dbAgent.support_contact._id).toBeUndefined();
+ });
+
+ it('should handle empty support_contact correctly', async () => {
+ const userId = new mongoose.Types.ObjectId();
+ const agentData = {
+ id: 'agent_test_empty_support',
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userId,
+ support_contact: {},
+ };
+
+ const agent = await createAgent(agentData);
+
+ // Verify empty support_contact is stored as empty object
+ expect(agent.support_contact).toEqual({});
+ expect(agent.support_contact._id).toBeUndefined();
+ });
+
+ it('should handle missing support_contact correctly', async () => {
+ const userId = new mongoose.Types.ObjectId();
+ const agentData = {
+ id: 'agent_test_no_support',
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userId,
+ };
+
+ const agent = await createAgent(agentData);
+
+ // Verify support_contact is undefined when not provided
+ expect(agent.support_contact).toBeUndefined();
+ });
+
+ describe('getListAgentsByAccess - Security Tests', () => {
+ let userA, userB;
+ let agentA1, agentA2, agentA3;
+
+ beforeEach(async () => {
+ Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
+ await Agent.deleteMany({});
+ await AclEntry.deleteMany({});
+
+ // Create two users
+ userA = new mongoose.Types.ObjectId();
+ userB = new mongoose.Types.ObjectId();
+
+ // Create agents for user A
+ agentA1 = await createAgent({
+ id: `agent_${uuidv4().slice(0, 12)}`,
+ name: 'Agent A1',
+ description: 'User A agent 1',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userA,
+ });
+
+ agentA2 = await createAgent({
+ id: `agent_${uuidv4().slice(0, 12)}`,
+ name: 'Agent A2',
+ description: 'User A agent 2',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userA,
+ });
+
+ agentA3 = await createAgent({
+ id: `agent_${uuidv4().slice(0, 12)}`,
+ name: 'Agent A3',
+ description: 'User A agent 3',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userA,
+ });
+ });
+
+ test('should return empty list when user has no accessible agents (empty accessibleIds)', async () => {
+ // User B has no agents and no shared agents
+ const result = await getListAgentsByAccess({
+ accessibleIds: [],
+ otherParams: {},
+ });
+
+ expect(result.data).toHaveLength(0);
+ expect(result.has_more).toBe(false);
+ expect(result.first_id).toBeNull();
+ expect(result.last_id).toBeNull();
+ });
+
+ test('should not return other users agents when accessibleIds is empty', async () => {
+ // User B trying to list agents with empty accessibleIds should not see User A's agents
+ const result = await getListAgentsByAccess({
+ accessibleIds: [],
+ otherParams: { author: userB },
+ });
+
+ expect(result.data).toHaveLength(0);
+ expect(result.has_more).toBe(false);
+ });
+
+ test('should only return agents in accessibleIds list', async () => {
+ // Give User B access to only one of User A's agents
+ const accessibleIds = [agentA1._id];
+
+ const result = await getListAgentsByAccess({
+ accessibleIds,
+ otherParams: {},
+ });
+
+ expect(result.data).toHaveLength(1);
+ expect(result.data[0].id).toBe(agentA1.id);
+ expect(result.data[0].name).toBe('Agent A1');
+ });
+
+ test('should return multiple accessible agents when provided', async () => {
+ // Give User B access to two of User A's agents
+ const accessibleIds = [agentA1._id, agentA3._id];
+
+ const result = await getListAgentsByAccess({
+ accessibleIds,
+ otherParams: {},
+ });
+
+ expect(result.data).toHaveLength(2);
+ const returnedIds = result.data.map((agent) => agent.id);
+ expect(returnedIds).toContain(agentA1.id);
+ expect(returnedIds).toContain(agentA3.id);
+ expect(returnedIds).not.toContain(agentA2.id);
+ });
+
+ test('should respect other query parameters while enforcing accessibleIds', async () => {
+ // Give access to all agents but filter by name
+ const accessibleIds = [agentA1._id, agentA2._id, agentA3._id];
+
+ const result = await getListAgentsByAccess({
+ accessibleIds,
+ otherParams: { name: 'Agent A2' },
+ });
+
+ expect(result.data).toHaveLength(1);
+ expect(result.data[0].id).toBe(agentA2.id);
+ });
+
+ test('should handle pagination correctly with accessibleIds filter', async () => {
+ // Create more agents
+ const moreAgents = [];
+ for (let i = 4; i <= 10; i++) {
+ const agent = await createAgent({
+ id: `agent_${uuidv4().slice(0, 12)}`,
+ name: `Agent A${i}`,
+ description: `User A agent ${i}`,
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userA,
+ });
+ moreAgents.push(agent);
+ }
+
+ // Give access to all agents
+ const allAgentIds = [agentA1, agentA2, agentA3, ...moreAgents].map((a) => a._id);
+
+ // First page
+ const page1 = await getListAgentsByAccess({
+ accessibleIds: allAgentIds,
+ otherParams: {},
+ limit: 5,
+ });
+
+ expect(page1.data).toHaveLength(5);
+ expect(page1.has_more).toBe(true);
+ expect(page1.after).toBeTruthy();
+
+ // Second page
+ const page2 = await getListAgentsByAccess({
+ accessibleIds: allAgentIds,
+ otherParams: {},
+ limit: 5,
+ after: page1.after,
+ });
+
+ expect(page2.data).toHaveLength(5);
+ expect(page2.has_more).toBe(false);
+
+ // Verify no overlap between pages
+ const page1Ids = page1.data.map((a) => a.id);
+ const page2Ids = page2.data.map((a) => a.id);
+ const intersection = page1Ids.filter((id) => page2Ids.includes(id));
+ expect(intersection).toHaveLength(0);
+ });
+
+ test('should return empty list when accessibleIds contains non-existent IDs', async () => {
+ // Try with non-existent agent IDs
+ const fakeIds = [new mongoose.Types.ObjectId(), new mongoose.Types.ObjectId()];
+
+ const result = await getListAgentsByAccess({
+ accessibleIds: fakeIds,
+ otherParams: {},
+ });
+
+ expect(result.data).toHaveLength(0);
+ expect(result.has_more).toBe(false);
+ });
+
+ test('should handle undefined accessibleIds as empty array', async () => {
+ // When accessibleIds is undefined, it should be treated as empty array
+ const result = await getListAgentsByAccess({
+ accessibleIds: undefined,
+ otherParams: {},
+ });
+
+ expect(result.data).toHaveLength(0);
+ expect(result.has_more).toBe(false);
+ });
+
+ test('should combine accessibleIds with author filter correctly', async () => {
+ // Create an agent for User B
+ const agentB1 = await createAgent({
+ id: `agent_${uuidv4().slice(0, 12)}`,
+ name: 'Agent B1',
+ description: 'User B agent 1',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userB,
+ });
+
+ // Give User B access to one of User A's agents
+ const accessibleIds = [agentA1._id, agentB1._id];
+
+ // Filter by author should further restrict the results
+ const result = await getListAgentsByAccess({
+ accessibleIds,
+ otherParams: { author: userB },
+ });
+
+ expect(result.data).toHaveLength(1);
+ expect(result.data[0].id).toBe(agentB1.id);
+ expect(result.data[0].author).toBe(userB.toString());
+ });
+ });
+});
+
+function createBasicAgent(overrides = {}) {
+ const defaults = {
+ id: `agent_${uuidv4()}`,
+ name: 'Test Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: new mongoose.Types.ObjectId(),
+ };
+ return createAgent({ ...defaults, ...overrides });
+}
+
+function createTestIds() {
+ return {
+ agentId: `agent_${uuidv4()}`,
+ authorId: new mongoose.Types.ObjectId(),
+ projectId: new mongoose.Types.ObjectId(),
+ fileId: uuidv4(),
+ };
+}
+
+function createFileOperations(agentId, fileIds, operation = 'add') {
+ return fileIds.map((fileId) =>
+ operation === 'add'
+ ? addAgentResourceFile({ agent_id: agentId, tool_resource: 'test_tool', file_id: fileId })
+ : removeAgentResourceFiles({
+ agent_id: agentId,
+ files: [{ tool_resource: 'test_tool', file_id: fileId }],
+ }),
+ );
+}
+
+function mockFindOneAndUpdateError(errorOnCall = 1) {
+ const original = Agent.findOneAndUpdate;
+ let callCount = 0;
+
+ Agent.findOneAndUpdate = jest.fn().mockImplementation((...args) => {
+ callCount++;
+ if (callCount === errorOnCall) {
+ throw new Error('Database connection lost');
+ }
+ return original.apply(Agent, args);
+ });
+
+ return () => {
+ Agent.findOneAndUpdate = original;
+ };
+}
+
+function generateVersionTestCases() {
+ const projectId1 = new mongoose.Types.ObjectId();
+ const projectId2 = new mongoose.Types.ObjectId();
+
+ return [
+ {
+ name: 'simple field update',
+ initial: {
+ name: 'Test Agent',
+ description: 'Initial description',
+ },
+ update: { name: 'Updated Name' },
+ duplicate: { name: 'Updated Name' },
+ },
+ {
+ name: 'object field update',
+ initial: {
+ model_parameters: { temperature: 0.7 },
+ },
+ update: { model_parameters: { temperature: 0.8 } },
+ duplicate: { model_parameters: { temperature: 0.8 } },
+ },
+ {
+ name: 'array field update',
+ initial: {
+ tools: ['tool1', 'tool2'],
+ },
+ update: { tools: ['tool2', 'tool3'] },
+ duplicate: { tools: ['tool2', 'tool3'] },
+ },
+ {
+ name: 'projectIds update',
+ initial: {
+ projectIds: [projectId1],
+ },
+ update: { projectIds: [projectId1, projectId2] },
+ duplicate: { projectIds: [projectId2, projectId1] },
+ },
+ ];
+}
diff --git a/api/models/Assistant.js b/api/models/Assistant.js
new file mode 100644
index 0000000000..be94d35d7d
--- /dev/null
+++ b/api/models/Assistant.js
@@ -0,0 +1,62 @@
+const { Assistant } = require('~/db/models');
+
+/**
+ * Update an assistant with new data without overwriting existing properties,
+ * or create a new assistant if it doesn't exist.
+ *
+ * @param {Object} searchParams - The search parameters to find the assistant to update.
+ * @param {string} searchParams.assistant_id - The ID of the assistant to update.
+ * @param {string} searchParams.user - The user ID of the assistant's author.
+ * @param {Object} updateData - An object containing the properties to update.
+ * @returns {Promise} The updated or newly created assistant document as a plain object.
+ */
+const updateAssistantDoc = async (searchParams, updateData) => {
+ const options = { new: true, upsert: true };
+ return await Assistant.findOneAndUpdate(searchParams, updateData, options).lean();
+};
+
+/**
+ * Retrieves an assistant document based on the provided ID.
+ *
+ * @param {Object} searchParams - The search parameters to find the assistant to update.
+ * @param {string} searchParams.assistant_id - The ID of the assistant to update.
+ * @param {string} searchParams.user - The user ID of the assistant's author.
+ * @returns {Promise} The assistant document as a plain object, or null if not found.
+ */
+const getAssistant = async (searchParams) => await Assistant.findOne(searchParams).lean();
+
+/**
+ * Retrieves all assistants that match the given search parameters.
+ *
+ * @param {Object} searchParams - The search parameters to find matching assistants.
+ * @param {Object} [select] - Optional. Specifies which document fields to include or exclude.
+ * @returns {Promise>} A promise that resolves to an array of assistant documents as plain objects.
+ */
+const getAssistants = async (searchParams, select = null) => {
+ let query = Assistant.find(searchParams);
+
+ if (select) {
+ query = query.select(select);
+ }
+
+ return await query.lean();
+};
+
+/**
+ * Deletes an assistant based on the provided ID.
+ *
+ * @param {Object} searchParams - The search parameters to find the assistant to delete.
+ * @param {string} searchParams.assistant_id - The ID of the assistant to delete.
+ * @param {string} searchParams.user - The user ID of the assistant's author.
+ * @returns {Promise} Resolves when the assistant has been successfully deleted.
+ */
+const deleteAssistant = async (searchParams) => {
+ return await Assistant.findOneAndDelete(searchParams);
+};
+
+module.exports = {
+ updateAssistantDoc,
+ deleteAssistant,
+ getAssistants,
+ getAssistant,
+};
diff --git a/api/models/Banner.js b/api/models/Banner.js
new file mode 100644
index 0000000000..42ad1599ed
--- /dev/null
+++ b/api/models/Banner.js
@@ -0,0 +1,28 @@
+const { logger } = require('@librechat/data-schemas');
+const { Banner } = require('~/db/models');
+
+/**
+ * Retrieves the current active banner.
+ * @returns {Promise} The active banner object or null if no active banner is found.
+ */
+const getBanner = async (user) => {
+ try {
+ const now = new Date();
+ const banner = await Banner.findOne({
+ displayFrom: { $lte: now },
+ $or: [{ displayTo: { $gte: now } }, { displayTo: null }],
+ type: 'banner',
+ }).lean();
+
+ if (!banner || banner.isPublic || user) {
+ return banner;
+ }
+
+ return null;
+ } catch (error) {
+ logger.error('[getBanners] Error getting banners', error);
+ throw new Error('Error getting banners');
+ }
+};
+
+module.exports = { getBanner };
diff --git a/api/models/Categories.js b/api/models/Categories.js
new file mode 100644
index 0000000000..34bd2d8ed2
--- /dev/null
+++ b/api/models/Categories.js
@@ -0,0 +1,57 @@
+const { logger } = require('@librechat/data-schemas');
+
+const options = [
+ {
+ label: 'com_ui_idea',
+ value: 'idea',
+ },
+ {
+ label: 'com_ui_travel',
+ value: 'travel',
+ },
+ {
+ label: 'com_ui_teach_or_explain',
+ value: 'teach_or_explain',
+ },
+ {
+ label: 'com_ui_write',
+ value: 'write',
+ },
+ {
+ label: 'com_ui_shop',
+ value: 'shop',
+ },
+ {
+ label: 'com_ui_code',
+ value: 'code',
+ },
+ {
+ label: 'com_ui_misc',
+ value: 'misc',
+ },
+ {
+ label: 'com_ui_roleplay',
+ value: 'roleplay',
+ },
+ {
+ label: 'com_ui_finance',
+ value: 'finance',
+ },
+];
+
+module.exports = {
+ /**
+ * Retrieves the categories asynchronously.
+ * @returns {Promise} An array of category objects.
+ * @throws {Error} If there is an error retrieving the categories.
+ */
+ getCategories: async () => {
+ try {
+ // const categories = await Categories.find();
+ return options;
+ } catch (error) {
+ logger.error('Error getting categories', error);
+ return [];
+ }
+ },
+};
diff --git a/api/models/Conversation.js b/api/models/Conversation.js
index 1ff778e957..6428d3970a 100644
--- a/api/models/Conversation.js
+++ b/api/models/Conversation.js
@@ -1,122 +1,364 @@
-const mongoose = require('mongoose');
+const { logger } = require('@librechat/data-schemas');
+const { createTempChatExpirationDate } = require('@librechat/api');
const { getMessages, deleteMessages } = require('./Message');
+const { Conversation } = require('~/db/models');
-const convoSchema = mongoose.Schema({
- conversationId: {
- type: String,
- unique: true,
- required: true
- },
- parentMessageId: {
- type: String,
- required: true
- },
- title: {
- type: String,
- default: 'New conversation'
- },
- jailbreakConversationId: {
- type: String
- },
- conversationSignature: {
- type: String
- },
- clientId: {
- type: String
- },
- invocationId: {
- type: String
- },
- chatGptLabel: {
- type: String
- },
- promptPrefix: {
- type: String
- },
- model: {
- type: String
- },
- suggestions: [{ type: String }],
- messages: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Message' }],
- created: {
- type: Date,
- default: Date.now
- }
-});
-
-const Conversation =
- mongoose.models.Conversation || mongoose.model('Conversation', convoSchema);
-
-const getConvo = async (conversationId) => {
+/**
+ * Searches for a conversation by conversationId and returns a lean document with only conversationId and user.
+ * @param {string} conversationId - The conversation's ID.
+ * @returns {Promise<{conversationId: string, user: string} | null>} The conversation object with selected fields or null if not found.
+ */
+const searchConversation = async (conversationId) => {
try {
- return await Conversation.findOne({ conversationId }).exec();
+ return await Conversation.findOne({ conversationId }, 'conversationId user').lean();
} catch (error) {
- console.log(error);
- return { message: 'Error getting single conversation' };
+ logger.error('[searchConversation] Error searching conversation', error);
+ throw new Error('Error searching conversation');
+ }
+};
+
+/**
+ * Retrieves a single conversation for a given user and conversation ID.
+ * @param {string} user - The user's ID.
+ * @param {string} conversationId - The conversation's ID.
+ * @returns {Promise} The conversation object.
+ */
+const getConvo = async (user, conversationId) => {
+ try {
+ return await Conversation.findOne({ user, conversationId }).lean();
+ } catch (error) {
+ logger.error('[getConvo] Error getting single conversation', error);
+ throw new Error('Error getting single conversation');
+ }
+};
+
+const deleteNullOrEmptyConversations = async () => {
+ try {
+ const filter = {
+ $or: [
+ { conversationId: null },
+ { conversationId: '' },
+ { conversationId: { $exists: false } },
+ ],
+ };
+
+ const result = await Conversation.deleteMany(filter);
+
+ // Delete associated messages
+ const messageDeleteResult = await deleteMessages(filter);
+
+ logger.info(
+ `[deleteNullOrEmptyConversations] Deleted ${result.deletedCount} conversations and ${messageDeleteResult.deletedCount} messages`,
+ );
+
+ return {
+ conversations: result,
+ messages: messageDeleteResult,
+ };
+ } catch (error) {
+ logger.error('[deleteNullOrEmptyConversations] Error deleting conversations', error);
+ throw new Error('Error deleting conversations with null or empty conversationId');
+ }
+};
+
+/**
+ * Searches for a conversation by conversationId and returns associated file ids.
+ * @param {string} conversationId - The conversation's ID.
+ * @returns {Promise}
+ */
+const getConvoFiles = async (conversationId) => {
+ try {
+ return (await Conversation.findOne({ conversationId }, 'files').lean())?.files ?? [];
+ } catch (error) {
+ logger.error('[getConvoFiles] Error getting conversation files', error);
+ throw new Error('Error getting conversation files');
}
};
module.exports = {
- saveConvo: async ({ conversationId, title, ...convo }) => {
+ getConvoFiles,
+ searchConversation,
+ deleteNullOrEmptyConversations,
+ /**
+ * Saves a conversation to the database.
+ * @param {Object} req - The request object.
+ * @param {string} conversationId - The conversation's ID.
+ * @param {Object} metadata - Additional metadata to log for operation.
+ * @returns {Promise} The conversation object.
+ */
+ saveConvo: async (req, { conversationId, newConversationId, ...convo }, metadata) => {
try {
- const messages = await getMessages({ conversationId });
- const update = { ...convo, messages };
- if (title) {
- update.title = title;
+ if (metadata?.context) {
+ logger.debug(`[saveConvo] ${metadata.context}`);
}
- return await Conversation.findOneAndUpdate(
- { conversationId },
- { $set: update },
- { new: true, upsert: true }
- ).exec();
+ const messages = await getMessages({ conversationId }, '_id');
+ const update = { ...convo, messages, user: req.user.id };
+
+ if (newConversationId) {
+ update.conversationId = newConversationId;
+ }
+
+ if (req?.body?.isTemporary) {
+ try {
+ const appConfig = req.config;
+ update.expiredAt = createTempChatExpirationDate(appConfig?.interfaceConfig);
+ } catch (err) {
+ logger.error('Error creating temporary chat expiration date:', err);
+ logger.info(`---\`saveConvo\` context: ${metadata?.context}`);
+ update.expiredAt = null;
+ }
+ } else {
+ update.expiredAt = null;
+ }
+
+ /** @type {{ $set: Partial; $unset?: Record }} */
+ const updateOperation = { $set: update };
+ if (metadata && metadata.unsetFields && Object.keys(metadata.unsetFields).length > 0) {
+ updateOperation.$unset = metadata.unsetFields;
+ }
+
+ /** Note: the resulting Model object is necessary for Meilisearch operations */
+ const conversation = await Conversation.findOneAndUpdate(
+ { conversationId, user: req.user.id },
+ updateOperation,
+ {
+ new: true,
+ upsert: true,
+ },
+ );
+
+ return conversation.toObject();
} catch (error) {
- console.log(error);
+ logger.error('[saveConvo] Error saving conversation', error);
+ if (metadata && metadata?.context) {
+ logger.info(`[saveConvo] ${metadata.context}`);
+ }
return { message: 'Error saving conversation' };
}
},
- updateConvo: async ({ conversationId, ...update }) => {
+ bulkSaveConvos: async (conversations) => {
try {
- return await Conversation.findOneAndUpdate({ conversationId }, update, {
- new: true
- }).exec();
+ const bulkOps = conversations.map((convo) => ({
+ updateOne: {
+ filter: { conversationId: convo.conversationId, user: convo.user },
+ update: convo,
+ upsert: true,
+ timestamps: false,
+ },
+ }));
+
+ const result = await Conversation.bulkWrite(bulkOps);
+ return result;
} catch (error) {
- console.log(error);
- return { message: 'Error updating conversation' };
+ logger.error('[bulkSaveConvos] Error saving conversations in bulk', error);
+ throw new Error('Failed to save conversations in bulk.');
}
},
- // getConvos: async () => await Conversation.find({}).sort({ created: -1 }).exec(),
- getConvos: async (pageNumber = 1, pageSize = 12) => {
+ getConvosByCursor: async (
+ user,
+ {
+ cursor,
+ limit = 25,
+ isArchived = false,
+ tags,
+ search,
+ sortBy = 'createdAt',
+ sortDirection = 'desc',
+ } = {},
+ ) => {
+ const filters = [{ user }];
+ if (isArchived) {
+ filters.push({ isArchived: true });
+ } else {
+ filters.push({ $or: [{ isArchived: false }, { isArchived: { $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, { filter: `user = "${user}"` });
+ 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);
+ throw new Error('Error during meiliSearch');
+ }
+ }
+
+ const validSortFields = ['title', 'createdAt', 'updatedAt'];
+ if (!validSortFields.includes(sortBy)) {
+ throw new Error(
+ `Invalid sortBy field: ${sortBy}. Must be one of ${validSortFields.join(', ')}`,
+ );
+ }
+ const finalSortBy = sortBy;
+ const finalSortDirection = sortDirection === 'asc' ? 'asc' : 'desc';
+
+ let cursorFilter = null;
+ if (cursor) {
+ try {
+ const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString());
+ const { primary, secondary } = decoded;
+ const primaryValue = finalSortBy === 'title' ? primary : new Date(primary);
+ const secondaryValue = new Date(secondary);
+ const op = finalSortDirection === 'asc' ? '$gt' : '$lt';
+
+ cursorFilter = {
+ $or: [
+ { [finalSortBy]: { [op]: primaryValue } },
+ {
+ [finalSortBy]: primaryValue,
+ updatedAt: { [op]: secondaryValue },
+ },
+ ],
+ };
+ } catch (err) {
+ logger.warn('[getConvosByCursor] Invalid cursor format, starting from beginning');
+ }
+ if (cursorFilter) {
+ filters.push(cursorFilter);
+ }
+ }
+
+ const query = filters.length === 1 ? filters[0] : { $and: filters };
+
try {
- const skip = (pageNumber - 1) * pageSize;
- // const limit = pageNumber * pageSize;
+ const sortOrder = finalSortDirection === 'asc' ? 1 : -1;
+ const sortObj = { [finalSortBy]: sortOrder };
- const conversations = await Conversation.find({})
- .sort({ created: -1 })
- .skip(skip)
- // .limit(limit)
- .limit(pageSize)
- .exec();
+ if (finalSortBy !== 'updatedAt') {
+ sortObj.updatedAt = sortOrder;
+ }
- return conversations;
+ const convos = await Conversation.find(query)
+ .select(
+ 'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL',
+ )
+ .sort(sortObj)
+ .limit(limit + 1)
+ .lean();
+
+ let nextCursor = null;
+ if (convos.length > limit) {
+ const lastConvo = convos.pop();
+ const primaryValue = lastConvo[finalSortBy];
+ const primaryStr = finalSortBy === 'title' ? primaryValue : primaryValue.toISOString();
+ const secondaryStr = lastConvo.updatedAt.toISOString();
+ const composite = { primary: primaryStr, secondary: secondaryStr };
+ nextCursor = Buffer.from(JSON.stringify(composite)).toString('base64');
+ }
+
+ return { conversations: convos, nextCursor };
} catch (error) {
- console.log(error);
- return { message: 'Error getting conversations' };
+ logger.error('[getConvosByCursor] Error getting conversations', error);
+ throw new Error('Error getting conversations');
+ }
+ },
+ getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => {
+ try {
+ 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 convoMap = {};
+ limited.forEach((convo) => {
+ convoMap[convo.conversationId] = convo;
+ });
+
+ return { conversations: limited, nextCursor, convoMap };
+ } catch (error) {
+ logger.error('[getConvosQueried] Error getting conversations', error);
+ throw new Error('Error fetching conversations');
}
},
getConvo,
- getConvoTitle: async (conversationId) => {
+ /* chore: this method is not properly error handled */
+ getConvoTitle: async (user, conversationId) => {
try {
- const convo = await getConvo(conversationId);
- return convo.title;
+ const convo = await getConvo(user, conversationId);
+ /* ChatGPT Browser was triggering error here due to convo being saved later */
+ if (convo && !convo.title) {
+ return null;
+ } else {
+ // TypeError: Cannot read properties of null (reading 'title')
+ return convo?.title || 'New Chat';
+ }
} catch (error) {
- console.log(error);
- return { message: 'Error getting conversation title' };
+ logger.error('[getConvoTitle] Error getting conversation title', error);
+ throw new Error('Error getting conversation title');
+ }
+ },
+ /**
+ * Asynchronously deletes conversations and associated messages for a given user and filter.
+ *
+ * @async
+ * @function
+ * @param {string|ObjectId} user - The user's ID.
+ * @param {Object} filter - Additional filter criteria for the conversations to be deleted.
+ * @returns {Promise<{ n: number, ok: number, deletedCount: number, messages: { n: number, ok: number, deletedCount: number } }>}
+ * An object containing the count of deleted conversations and associated messages.
+ * @throws {Error} Throws an error if there's an issue with the database operations.
+ *
+ * @example
+ * const user = 'someUserId';
+ * const filter = { someField: 'someValue' };
+ * const result = await deleteConvos(user, filter);
+ * logger.error(result); // { n: 5, ok: 1, deletedCount: 5, messages: { n: 10, ok: 1, deletedCount: 10 } }
+ */
+ deleteConvos: async (user, filter) => {
+ 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;
}
},
- deleteConvos: async (filter) => {
- let deleteCount = await Conversation.deleteMany(filter).exec();
- deleteCount.messages = await deleteMessages(filter);
- return deleteCount;
- }
};
diff --git a/api/models/Conversation.spec.js b/api/models/Conversation.spec.js
new file mode 100644
index 0000000000..c5030aed3c
--- /dev/null
+++ b/api/models/Conversation.spec.js
@@ -0,0 +1,570 @@
+const mongoose = require('mongoose');
+const { v4: uuidv4 } = require('uuid');
+const { EModelEndpoint } = require('librechat-data-provider');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const {
+ deleteNullOrEmptyConversations,
+ searchConversation,
+ getConvosByCursor,
+ getConvosQueried,
+ getConvoFiles,
+ getConvoTitle,
+ deleteConvos,
+ saveConvo,
+ getConvo,
+} = require('./Conversation');
+jest.mock('~/server/services/Config/app');
+jest.mock('./Message');
+const { getMessages, deleteMessages } = require('./Message');
+
+const { Conversation } = require('~/db/models');
+
+describe('Conversation Operations', () => {
+ let mongoServer;
+ let mockReq;
+ let mockConversationData;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ // Clear database
+ await Conversation.deleteMany({});
+
+ // Reset mocks
+ jest.clearAllMocks();
+
+ // Default mock implementations
+ getMessages.mockResolvedValue([]);
+ deleteMessages.mockResolvedValue({ deletedCount: 0 });
+
+ mockReq = {
+ user: { id: 'user123' },
+ body: {},
+ config: {
+ interfaceConfig: {
+ temporaryChatRetention: 24, // Default 24 hours
+ },
+ },
+ };
+
+ mockConversationData = {
+ conversationId: uuidv4(),
+ title: 'Test Conversation',
+ endpoint: EModelEndpoint.openAI,
+ };
+ });
+
+ describe('saveConvo', () => {
+ it('should save a conversation for an authenticated user', async () => {
+ const result = await saveConvo(mockReq, mockConversationData);
+
+ expect(result.conversationId).toBe(mockConversationData.conversationId);
+ expect(result.user).toBe('user123');
+ expect(result.title).toBe('Test Conversation');
+ expect(result.endpoint).toBe(EModelEndpoint.openAI);
+
+ // Verify the conversation was actually saved to the database
+ const savedConvo = await Conversation.findOne({
+ conversationId: mockConversationData.conversationId,
+ user: 'user123',
+ });
+ expect(savedConvo).toBeTruthy();
+ expect(savedConvo.title).toBe('Test Conversation');
+ });
+
+ it('should query messages when saving a conversation', async () => {
+ // Mock messages as ObjectIds
+ const mongoose = require('mongoose');
+ const mockMessages = [new mongoose.Types.ObjectId(), new mongoose.Types.ObjectId()];
+ getMessages.mockResolvedValue(mockMessages);
+
+ await saveConvo(mockReq, mockConversationData);
+
+ // Verify that getMessages was called with correct parameters
+ expect(getMessages).toHaveBeenCalledWith(
+ { conversationId: mockConversationData.conversationId },
+ '_id',
+ );
+ });
+
+ it('should handle newConversationId when provided', async () => {
+ const newConversationId = uuidv4();
+ const result = await saveConvo(mockReq, {
+ ...mockConversationData,
+ newConversationId,
+ });
+
+ expect(result.conversationId).toBe(newConversationId);
+ });
+
+ it('should handle unsetFields metadata', async () => {
+ const metadata = {
+ unsetFields: { someField: 1 },
+ };
+
+ await saveConvo(mockReq, mockConversationData, metadata);
+
+ const savedConvo = await Conversation.findOne({
+ conversationId: mockConversationData.conversationId,
+ });
+ expect(savedConvo.someField).toBeUndefined();
+ });
+ });
+
+ describe('isTemporary conversation handling', () => {
+ it('should save a conversation with expiredAt when isTemporary is true', async () => {
+ // Mock app config with 24 hour retention
+ mockReq.config.interfaceConfig.temporaryChatRetention = 24;
+
+ mockReq.body = { isTemporary: true };
+
+ const beforeSave = new Date();
+ const result = await saveConvo(mockReq, mockConversationData);
+ const afterSave = new Date();
+
+ expect(result.conversationId).toBe(mockConversationData.conversationId);
+ expect(result.expiredAt).toBeDefined();
+ expect(result.expiredAt).toBeInstanceOf(Date);
+
+ // Verify expiredAt is approximately 24 hours in the future
+ const expectedExpirationTime = new Date(beforeSave.getTime() + 24 * 60 * 60 * 1000);
+ const actualExpirationTime = new Date(result.expiredAt);
+
+ expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
+ expectedExpirationTime.getTime() - 1000,
+ );
+ expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
+ new Date(afterSave.getTime() + 24 * 60 * 60 * 1000 + 1000).getTime(),
+ );
+ });
+
+ it('should save a conversation without expiredAt when isTemporary is false', async () => {
+ mockReq.body = { isTemporary: false };
+
+ const result = await saveConvo(mockReq, mockConversationData);
+
+ expect(result.conversationId).toBe(mockConversationData.conversationId);
+ expect(result.expiredAt).toBeNull();
+ });
+
+ it('should save a conversation without expiredAt when isTemporary is not provided', async () => {
+ // No isTemporary in body
+ mockReq.body = {};
+
+ const result = await saveConvo(mockReq, mockConversationData);
+
+ expect(result.conversationId).toBe(mockConversationData.conversationId);
+ expect(result.expiredAt).toBeNull();
+ });
+
+ it('should use custom retention period from config', async () => {
+ // Mock app config with 48 hour retention
+ mockReq.config.interfaceConfig.temporaryChatRetention = 48;
+
+ mockReq.body = { isTemporary: true };
+
+ const beforeSave = new Date();
+ const result = await saveConvo(mockReq, mockConversationData);
+
+ expect(result.expiredAt).toBeDefined();
+
+ // Verify expiredAt is approximately 48 hours in the future
+ const expectedExpirationTime = new Date(beforeSave.getTime() + 48 * 60 * 60 * 1000);
+ const actualExpirationTime = new Date(result.expiredAt);
+
+ expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
+ expectedExpirationTime.getTime() - 1000,
+ );
+ expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
+ expectedExpirationTime.getTime() + 1000,
+ );
+ });
+
+ it('should handle minimum retention period (1 hour)', async () => {
+ // Mock app config with less than minimum retention
+ mockReq.config.interfaceConfig.temporaryChatRetention = 0.5; // Half hour - should be clamped to 1 hour
+
+ mockReq.body = { isTemporary: true };
+
+ const beforeSave = new Date();
+ const result = await saveConvo(mockReq, mockConversationData);
+
+ expect(result.expiredAt).toBeDefined();
+
+ // Verify expiredAt is approximately 1 hour in the future (minimum)
+ const expectedExpirationTime = new Date(beforeSave.getTime() + 1 * 60 * 60 * 1000);
+ const actualExpirationTime = new Date(result.expiredAt);
+
+ expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
+ expectedExpirationTime.getTime() - 1000,
+ );
+ expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
+ expectedExpirationTime.getTime() + 1000,
+ );
+ });
+
+ it('should handle maximum retention period (8760 hours)', async () => {
+ // Mock app config with more than maximum retention
+ mockReq.config.interfaceConfig.temporaryChatRetention = 10000; // Should be clamped to 8760 hours
+
+ mockReq.body = { isTemporary: true };
+
+ const beforeSave = new Date();
+ const result = await saveConvo(mockReq, mockConversationData);
+
+ expect(result.expiredAt).toBeDefined();
+
+ // Verify expiredAt is approximately 8760 hours (1 year) in the future
+ const expectedExpirationTime = new Date(beforeSave.getTime() + 8760 * 60 * 60 * 1000);
+ const actualExpirationTime = new Date(result.expiredAt);
+
+ expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
+ expectedExpirationTime.getTime() - 1000,
+ );
+ expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
+ expectedExpirationTime.getTime() + 1000,
+ );
+ });
+
+ it('should handle missing config gracefully', async () => {
+ // Simulate missing config - should use default retention period
+ delete mockReq.config;
+
+ mockReq.body = { isTemporary: true };
+
+ const beforeSave = new Date();
+ const result = await saveConvo(mockReq, mockConversationData);
+ const afterSave = new Date();
+
+ // Should still save the conversation with default retention period (30 days)
+ expect(result.conversationId).toBe(mockConversationData.conversationId);
+ expect(result.expiredAt).toBeDefined();
+ expect(result.expiredAt).toBeInstanceOf(Date);
+
+ // Verify expiredAt is approximately 30 days in the future (720 hours)
+ const expectedExpirationTime = new Date(beforeSave.getTime() + 720 * 60 * 60 * 1000);
+ const actualExpirationTime = new Date(result.expiredAt);
+
+ expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
+ expectedExpirationTime.getTime() - 1000,
+ );
+ expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
+ new Date(afterSave.getTime() + 720 * 60 * 60 * 1000 + 1000).getTime(),
+ );
+ });
+
+ it('should use default retention when config is not provided', async () => {
+ // Mock getAppConfig to return empty config
+ mockReq.config = {}; // Empty config
+
+ mockReq.body = { isTemporary: true };
+
+ const beforeSave = new Date();
+ const result = await saveConvo(mockReq, mockConversationData);
+
+ expect(result.expiredAt).toBeDefined();
+
+ // Default retention is 30 days (720 hours)
+ const expectedExpirationTime = new Date(beforeSave.getTime() + 30 * 24 * 60 * 60 * 1000);
+ const actualExpirationTime = new Date(result.expiredAt);
+
+ expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
+ expectedExpirationTime.getTime() - 1000,
+ );
+ expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
+ expectedExpirationTime.getTime() + 1000,
+ );
+ });
+
+ it('should update expiredAt when saving existing temporary conversation', async () => {
+ // First save a temporary conversation
+ mockReq.config.interfaceConfig.temporaryChatRetention = 24;
+
+ mockReq.body = { isTemporary: true };
+ const firstSave = await saveConvo(mockReq, mockConversationData);
+ const originalExpiredAt = firstSave.expiredAt;
+
+ // Wait a bit to ensure time difference
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ // Save again with same conversationId but different title
+ const updatedData = { ...mockConversationData, title: 'Updated Title' };
+ const secondSave = await saveConvo(mockReq, updatedData);
+
+ // Should update title and create new expiredAt
+ expect(secondSave.title).toBe('Updated Title');
+ expect(secondSave.expiredAt).toBeDefined();
+ expect(new Date(secondSave.expiredAt).getTime()).toBeGreaterThan(
+ new Date(originalExpiredAt).getTime(),
+ );
+ });
+
+ it('should not set expiredAt when updating non-temporary conversation', async () => {
+ // First save a non-temporary conversation
+ mockReq.body = { isTemporary: false };
+ const firstSave = await saveConvo(mockReq, mockConversationData);
+ expect(firstSave.expiredAt).toBeNull();
+
+ // Update without isTemporary flag
+ mockReq.body = {};
+ const updatedData = { ...mockConversationData, title: 'Updated Title' };
+ const secondSave = await saveConvo(mockReq, updatedData);
+
+ expect(secondSave.title).toBe('Updated Title');
+ expect(secondSave.expiredAt).toBeNull();
+ });
+
+ it('should filter out expired conversations in getConvosByCursor', async () => {
+ // Create some test conversations
+ const nonExpiredConvo = await Conversation.create({
+ conversationId: uuidv4(),
+ user: 'user123',
+ title: 'Non-expired',
+ endpoint: EModelEndpoint.openAI,
+ expiredAt: null,
+ updatedAt: new Date(),
+ });
+
+ await Conversation.create({
+ conversationId: uuidv4(),
+ user: 'user123',
+ title: 'Future expired',
+ endpoint: EModelEndpoint.openAI,
+ expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours from now
+ updatedAt: new Date(),
+ });
+
+ // Mock Meili search
+ Conversation.meiliSearch = jest.fn().mockResolvedValue({ hits: [] });
+
+ const result = await getConvosByCursor('user123');
+
+ // Should only return conversations with null or non-existent expiredAt
+ expect(result.conversations).toHaveLength(1);
+ expect(result.conversations[0].conversationId).toBe(nonExpiredConvo.conversationId);
+ });
+
+ it('should filter out expired conversations in getConvosQueried', async () => {
+ // Create test conversations
+ const nonExpiredConvo = await Conversation.create({
+ conversationId: uuidv4(),
+ user: 'user123',
+ title: 'Non-expired',
+ endpoint: EModelEndpoint.openAI,
+ expiredAt: null,
+ });
+
+ const expiredConvo = await Conversation.create({
+ conversationId: uuidv4(),
+ user: 'user123',
+ title: 'Expired',
+ endpoint: EModelEndpoint.openAI,
+ expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
+ });
+
+ const convoIds = [
+ { conversationId: nonExpiredConvo.conversationId },
+ { conversationId: expiredConvo.conversationId },
+ ];
+
+ const result = await getConvosQueried('user123', convoIds);
+
+ // Should only return the non-expired conversation
+ expect(result.conversations).toHaveLength(1);
+ expect(result.conversations[0].conversationId).toBe(nonExpiredConvo.conversationId);
+ expect(result.convoMap[nonExpiredConvo.conversationId]).toBeDefined();
+ expect(result.convoMap[expiredConvo.conversationId]).toBeUndefined();
+ });
+ });
+
+ describe('searchConversation', () => {
+ it('should find a conversation by conversationId', async () => {
+ await Conversation.create({
+ conversationId: mockConversationData.conversationId,
+ user: 'user123',
+ title: 'Test',
+ endpoint: EModelEndpoint.openAI,
+ });
+
+ const result = await searchConversation(mockConversationData.conversationId);
+
+ expect(result).toBeTruthy();
+ expect(result.conversationId).toBe(mockConversationData.conversationId);
+ expect(result.user).toBe('user123');
+ expect(result.title).toBeUndefined(); // Only returns conversationId and user
+ });
+
+ it('should return null if conversation not found', async () => {
+ const result = await searchConversation('non-existent-id');
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('getConvo', () => {
+ it('should retrieve a conversation for a user', async () => {
+ await Conversation.create({
+ conversationId: mockConversationData.conversationId,
+ user: 'user123',
+ title: 'Test Conversation',
+ endpoint: EModelEndpoint.openAI,
+ });
+
+ const result = await getConvo('user123', mockConversationData.conversationId);
+
+ expect(result.conversationId).toBe(mockConversationData.conversationId);
+ expect(result.user).toBe('user123');
+ expect(result.title).toBe('Test Conversation');
+ });
+
+ it('should return null if conversation not found', async () => {
+ const result = await getConvo('user123', 'non-existent-id');
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('getConvoTitle', () => {
+ it('should return the conversation title', async () => {
+ await Conversation.create({
+ conversationId: mockConversationData.conversationId,
+ user: 'user123',
+ title: 'Test Title',
+ endpoint: EModelEndpoint.openAI,
+ });
+
+ const result = await getConvoTitle('user123', mockConversationData.conversationId);
+ expect(result).toBe('Test Title');
+ });
+
+ it('should return null if conversation has no title', async () => {
+ await Conversation.create({
+ conversationId: mockConversationData.conversationId,
+ user: 'user123',
+ title: null,
+ endpoint: EModelEndpoint.openAI,
+ });
+
+ const result = await getConvoTitle('user123', mockConversationData.conversationId);
+ expect(result).toBeNull();
+ });
+
+ it('should return "New Chat" if conversation not found', async () => {
+ const result = await getConvoTitle('user123', 'non-existent-id');
+ expect(result).toBe('New Chat');
+ });
+ });
+
+ describe('getConvoFiles', () => {
+ it('should return conversation files', async () => {
+ const files = ['file1', 'file2'];
+ await Conversation.create({
+ conversationId: mockConversationData.conversationId,
+ user: 'user123',
+ endpoint: EModelEndpoint.openAI,
+ files,
+ });
+
+ const result = await getConvoFiles(mockConversationData.conversationId);
+ expect(result).toEqual(files);
+ });
+
+ it('should return empty array if no files', async () => {
+ await Conversation.create({
+ conversationId: mockConversationData.conversationId,
+ user: 'user123',
+ endpoint: EModelEndpoint.openAI,
+ });
+
+ const result = await getConvoFiles(mockConversationData.conversationId);
+ expect(result).toEqual([]);
+ });
+
+ it('should return empty array if conversation not found', async () => {
+ const result = await getConvoFiles('non-existent-id');
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('deleteConvos', () => {
+ it('should delete conversations and associated messages', async () => {
+ await Conversation.create({
+ conversationId: mockConversationData.conversationId,
+ user: 'user123',
+ title: 'To Delete',
+ endpoint: EModelEndpoint.openAI,
+ });
+
+ deleteMessages.mockResolvedValue({ deletedCount: 5 });
+
+ const result = await deleteConvos('user123', {
+ conversationId: mockConversationData.conversationId,
+ });
+
+ expect(result.deletedCount).toBe(1);
+ expect(result.messages.deletedCount).toBe(5);
+ expect(deleteMessages).toHaveBeenCalledWith({
+ conversationId: { $in: [mockConversationData.conversationId] },
+ });
+
+ // Verify conversation was deleted
+ const deletedConvo = await Conversation.findOne({
+ conversationId: mockConversationData.conversationId,
+ });
+ expect(deletedConvo).toBeNull();
+ });
+
+ it('should throw error if no conversations found', async () => {
+ await expect(deleteConvos('user123', { conversationId: 'non-existent' })).rejects.toThrow(
+ 'Conversation not found or already deleted.',
+ );
+ });
+ });
+
+ describe('deleteNullOrEmptyConversations', () => {
+ it('should delete conversations with null, empty, or missing conversationIds', async () => {
+ // Since conversationId is required by the schema, we can't create documents with null/missing IDs
+ // This test should verify the function works when such documents exist (e.g., from data corruption)
+
+ // For this test, let's create a valid conversation and verify the function doesn't delete it
+ await Conversation.create({
+ conversationId: mockConversationData.conversationId,
+ user: 'user4',
+ endpoint: EModelEndpoint.openAI,
+ });
+
+ deleteMessages.mockResolvedValue({ deletedCount: 0 });
+
+ const result = await deleteNullOrEmptyConversations();
+
+ expect(result.conversations.deletedCount).toBe(0); // No invalid conversations to delete
+ expect(result.messages.deletedCount).toBe(0);
+
+ // Verify valid conversation remains
+ const remainingConvos = await Conversation.find({});
+ expect(remainingConvos).toHaveLength(1);
+ expect(remainingConvos[0].conversationId).toBe(mockConversationData.conversationId);
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('should handle database errors in saveConvo', async () => {
+ // Force a database error by disconnecting
+ await mongoose.disconnect();
+
+ const result = await saveConvo(mockReq, mockConversationData);
+
+ expect(result).toEqual({ message: 'Error saving conversation' });
+
+ // Reconnect for other tests
+ await mongoose.connect(mongoServer.getUri());
+ });
+ });
+});
diff --git a/api/models/ConversationTag.js b/api/models/ConversationTag.js
new file mode 100644
index 0000000000..47a6c2bbf5
--- /dev/null
+++ b/api/models/ConversationTag.js
@@ -0,0 +1,284 @@
+const { logger } = require('@librechat/data-schemas');
+const { ConversationTag, Conversation } = require('~/db/models');
+
+/**
+ * Retrieves all conversation tags for a user.
+ * @param {string} user - The user ID.
+ * @returns {Promise} An array of conversation tags.
+ */
+const getConversationTags = async (user) => {
+ try {
+ return await ConversationTag.find({ user }).sort({ position: 1 }).lean();
+ } catch (error) {
+ logger.error('[getConversationTags] Error getting conversation tags', error);
+ throw new Error('Error getting conversation tags');
+ }
+};
+
+/**
+ * Creates a new conversation tag.
+ * @param {string} user - The user ID.
+ * @param {Object} data - The tag data.
+ * @param {string} data.tag - The tag name.
+ * @param {string} [data.description] - The tag description.
+ * @param {boolean} [data.addToConversation] - Whether to add the tag to a conversation.
+ * @param {string} [data.conversationId] - The conversation ID to add the tag to.
+ * @returns {Promise} The created tag.
+ */
+const createConversationTag = async (user, data) => {
+ try {
+ const { tag, description, addToConversation, conversationId } = data;
+
+ const existingTag = await ConversationTag.findOne({ user, tag }).lean();
+ if (existingTag) {
+ return existingTag;
+ }
+
+ const maxPosition = await ConversationTag.findOne({ user }).sort('-position').lean();
+ const position = (maxPosition?.position || 0) + 1;
+
+ const newTag = await ConversationTag.findOneAndUpdate(
+ { tag, user },
+ {
+ tag,
+ user,
+ count: addToConversation ? 1 : 0,
+ position,
+ description,
+ $setOnInsert: { createdAt: new Date() },
+ },
+ {
+ new: true,
+ upsert: true,
+ lean: true,
+ },
+ );
+
+ if (addToConversation && conversationId) {
+ await Conversation.findOneAndUpdate(
+ { user, conversationId },
+ { $addToSet: { tags: tag } },
+ { new: true },
+ );
+ }
+
+ return newTag;
+ } catch (error) {
+ logger.error('[createConversationTag] Error creating conversation tag', error);
+ throw new Error('Error creating conversation tag');
+ }
+};
+
+/**
+ * Updates an existing conversation tag.
+ * @param {string} user - The user ID.
+ * @param {string} oldTag - The current tag name.
+ * @param {Object} data - The updated tag data.
+ * @param {string} [data.tag] - The new tag name.
+ * @param {string} [data.description] - The updated description.
+ * @param {number} [data.position] - The new position.
+ * @returns {Promise} The updated tag.
+ */
+const updateConversationTag = async (user, oldTag, data) => {
+ try {
+ const { tag: newTag, description, position } = data;
+
+ const existingTag = await ConversationTag.findOne({ user, tag: oldTag }).lean();
+ if (!existingTag) {
+ return null;
+ }
+
+ if (newTag && newTag !== oldTag) {
+ const tagAlreadyExists = await ConversationTag.findOne({ user, tag: newTag }).lean();
+ if (tagAlreadyExists) {
+ throw new Error('Tag already exists');
+ }
+
+ await Conversation.updateMany({ user, tags: oldTag }, { $set: { 'tags.$': newTag } });
+ }
+
+ const updateData = {};
+ if (newTag) {
+ updateData.tag = newTag;
+ }
+ if (description !== undefined) {
+ updateData.description = description;
+ }
+ if (position !== undefined) {
+ await adjustPositions(user, existingTag.position, position);
+ updateData.position = position;
+ }
+
+ return await ConversationTag.findOneAndUpdate({ user, tag: oldTag }, updateData, {
+ new: true,
+ lean: true,
+ });
+ } catch (error) {
+ logger.error('[updateConversationTag] Error updating conversation tag', error);
+ throw new Error('Error updating conversation tag');
+ }
+};
+
+/**
+ * Adjusts positions of tags when a tag's position is changed.
+ * @param {string} user - The user ID.
+ * @param {number} oldPosition - The old position of the tag.
+ * @param {number} newPosition - The new position of the tag.
+ * @returns {Promise}
+ */
+const adjustPositions = async (user, oldPosition, newPosition) => {
+ if (oldPosition === newPosition) {
+ return;
+ }
+
+ const update = oldPosition < newPosition ? { $inc: { position: -1 } } : { $inc: { position: 1 } };
+ const position =
+ oldPosition < newPosition
+ ? {
+ $gt: Math.min(oldPosition, newPosition),
+ $lte: Math.max(oldPosition, newPosition),
+ }
+ : {
+ $gte: Math.min(oldPosition, newPosition),
+ $lt: Math.max(oldPosition, newPosition),
+ };
+
+ await ConversationTag.updateMany(
+ {
+ user,
+ position,
+ },
+ update,
+ );
+};
+
+/**
+ * Deletes a conversation tag.
+ * @param {string} user - The user ID.
+ * @param {string} tag - The tag to delete.
+ * @returns {Promise} The deleted tag.
+ */
+const deleteConversationTag = async (user, tag) => {
+ try {
+ const deletedTag = await ConversationTag.findOneAndDelete({ user, tag }).lean();
+ if (!deletedTag) {
+ return null;
+ }
+
+ await Conversation.updateMany({ user, tags: tag }, { $pull: { tags: tag } });
+
+ await ConversationTag.updateMany(
+ { user, position: { $gt: deletedTag.position } },
+ { $inc: { position: -1 } },
+ );
+
+ return deletedTag;
+ } catch (error) {
+ logger.error('[deleteConversationTag] Error deleting conversation tag', error);
+ throw new Error('Error deleting conversation tag');
+ }
+};
+
+/**
+ * Updates tags for a specific conversation.
+ * @param {string} user - The user ID.
+ * @param {string} conversationId - The conversation ID.
+ * @param {string[]} tags - The new set of tags for the conversation.
+ * @returns {Promise} The updated list of tags for the conversation.
+ */
+const updateTagsForConversation = async (user, conversationId, tags) => {
+ try {
+ const conversation = await Conversation.findOne({ user, conversationId }).lean();
+ if (!conversation) {
+ throw new Error('Conversation not found');
+ }
+
+ const oldTags = new Set(conversation.tags);
+ const newTags = new Set(tags);
+
+ const addedTags = [...newTags].filter((tag) => !oldTags.has(tag));
+ const removedTags = [...oldTags].filter((tag) => !newTags.has(tag));
+
+ const bulkOps = [];
+
+ for (const tag of addedTags) {
+ bulkOps.push({
+ updateOne: {
+ filter: { user, tag },
+ update: { $inc: { count: 1 } },
+ upsert: true,
+ },
+ });
+ }
+
+ for (const tag of removedTags) {
+ bulkOps.push({
+ updateOne: {
+ filter: { user, tag },
+ update: { $inc: { count: -1 } },
+ },
+ });
+ }
+
+ if (bulkOps.length > 0) {
+ await ConversationTag.bulkWrite(bulkOps);
+ }
+
+ const updatedConversation = (
+ await Conversation.findOneAndUpdate(
+ { user, conversationId },
+ { $set: { tags: [...newTags] } },
+ { new: true },
+ )
+ ).toObject();
+
+ return updatedConversation.tags;
+ } catch (error) {
+ logger.error('[updateTagsForConversation] Error updating tags', error);
+ throw new Error('Error updating tags for conversation');
+ }
+};
+
+/**
+ * Increments tag counts for existing tags only.
+ * @param {string} user - The user ID.
+ * @param {string[]} tags - Array of tag names to increment
+ * @returns {Promise}
+ */
+const bulkIncrementTagCounts = async (user, tags) => {
+ if (!tags || tags.length === 0) {
+ return;
+ }
+
+ try {
+ const uniqueTags = [...new Set(tags.filter(Boolean))];
+ if (uniqueTags.length === 0) {
+ return;
+ }
+
+ const bulkOps = uniqueTags.map((tag) => ({
+ updateOne: {
+ filter: { user, tag },
+ update: { $inc: { count: 1 } },
+ },
+ }));
+
+ const result = await ConversationTag.bulkWrite(bulkOps);
+ if (result && result.modifiedCount > 0) {
+ logger.debug(
+ `user: ${user} | Incremented tag counts - modified ${result.modifiedCount} tags`,
+ );
+ }
+ } catch (error) {
+ logger.error('[bulkIncrementTagCounts] Error incrementing tag counts', error);
+ }
+};
+
+module.exports = {
+ getConversationTags,
+ createConversationTag,
+ updateConversationTag,
+ deleteConversationTag,
+ bulkIncrementTagCounts,
+ updateTagsForConversation,
+};
diff --git a/api/models/CustomGpt.js b/api/models/CustomGpt.js
deleted file mode 100644
index 33bb75b124..0000000000
--- a/api/models/CustomGpt.js
+++ /dev/null
@@ -1,82 +0,0 @@
-const mongoose = require('mongoose');
-
-const customGptSchema = mongoose.Schema({
- chatGptLabel: {
- type: String,
- required: true
- },
- promptPrefix: {
- type: String
- },
- value: {
- type: String,
- required: true
- },
- created: {
- type: Date,
- default: Date.now
- }
-});
-
-const CustomGpt = mongoose.models.CustomGpt || mongoose.model('CustomGpt', customGptSchema);
-
-const createCustomGpt = async ({ chatGptLabel, promptPrefix, value }) => {
- try {
- await CustomGpt.create({
- chatGptLabel,
- promptPrefix,
- value
- });
- return { chatGptLabel, promptPrefix, value };
- } catch (error) {
- console.error(error);
- return { customGpt: 'Error saving customGpt' };
- }
-};
-
-module.exports = {
- getCustomGpts: async (filter) => {
- try {
- return await CustomGpt.find(filter).exec();
- } catch (error) {
- console.error(error);
- return { customGpt: 'Error getting customGpts' };
- }
- },
- updateCustomGpt: async ({ value, ...update }) => {
- try {
- const customGpt = await CustomGpt.findOne({ value }).exec();
-
- if (!customGpt) {
- return await createCustomGpt({ value, ...update });
- } else {
- return await CustomGpt.findOneAndUpdate({ value }, update, {
- new: true,
- upsert: true
- }).exec();
- }
- } catch (error) {
- console.log(error);
- return { message: 'Error updating customGpt' };
- }
- },
- updateByLabel: async ({ prevLabel, ...update }) => {
- try {
- return await CustomGpt.findOneAndUpdate({ chatGptLabel: prevLabel }, update, {
- new: true,
- upsert: true
- }).exec();
- } catch (error) {
- console.log(error);
- return { message: 'Error updating customGpt' };
- }
- },
- deleteCustomGpts: async (filter) => {
- try {
- return await CustomGpt.deleteMany(filter).exec();
- } catch (error) {
- console.error(error);
- return { customGpt: 'Error deleting customGpts' };
- }
- }
-};
diff --git a/api/models/File.js b/api/models/File.js
new file mode 100644
index 0000000000..5e90c86fe4
--- /dev/null
+++ b/api/models/File.js
@@ -0,0 +1,179 @@
+const { logger } = require('@librechat/data-schemas');
+const { EToolResources, FileContext } = require('librechat-data-provider');
+const { File } = require('~/db/models');
+
+/**
+ * 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} A promise that resolves to the file document or null.
+ */
+const findFileById = async (file_id, options = {}) => {
+ return await File.findOne({ file_id, ...options }).lean();
+};
+
+/**
+ * Retrieves files matching a given filter, sorted by the most recently updated.
+ * @param {Object} filter - The filter criteria to apply.
+ * @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>} A promise that resolves to an array of file documents.
+ */
+const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }) => {
+ const sortOptions = { updatedAt: -1, ..._sortOptions };
+ return await File.find(filter).select(selectFields).sort(sortOptions).lean();
+};
+
+/**
+ * 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
+ * @param {Set} toolResourceSet - Optional filter for tool resources
+ * @returns {Promise>} Files that match the criteria
+ */
+const getToolFilesByIds = async (fileIds, toolResourceSet) => {
+ if (!fileIds || !fileIds.length || !toolResourceSet?.size) {
+ return [];
+ }
+
+ try {
+ const filter = {
+ file_id: { $in: fileIds },
+ $or: [],
+ };
+
+ if (toolResourceSet.has(EToolResources.context)) {
+ filter.$or.push({ text: { $exists: true, $ne: null }, context: FileContext.agents });
+ }
+ 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 };
+
+ return await getFiles(filter, sortOptions, selectFields);
+ } catch (error) {
+ logger.error('[getToolFilesByIds] Error retrieving tool files:', error);
+ throw new Error('Error retrieving tool files');
+ }
+};
+
+/**
+ * Creates a new file with a TTL of 1 hour.
+ * @param {MongoFile} data - The file data to be created, must contain file_id.
+ * @param {boolean} disableTTL - Whether to disable the TTL.
+ * @returns {Promise} A promise that resolves to the created file document.
+ */
+const createFile = async (data, disableTTL) => {
+ const fileData = {
+ ...data,
+ expiresAt: new Date(Date.now() + 3600 * 1000),
+ };
+
+ if (disableTTL) {
+ delete fileData.expiresAt;
+ }
+
+ return await File.findOneAndUpdate({ file_id: data.file_id }, fileData, {
+ new: true,
+ upsert: true,
+ }).lean();
+};
+
+/**
+ * Updates a file identified by file_id with new data and removes the TTL.
+ * @param {MongoFile} data - The data to update, must contain file_id.
+ * @returns {Promise} A promise that resolves to the updated file document.
+ */
+const updateFile = async (data) => {
+ const { file_id, ...update } = data;
+ const updateOperation = {
+ $set: update,
+ $unset: { expiresAt: '' }, // Remove the expiresAt field to prevent TTL
+ };
+ return await File.findOneAndUpdate({ file_id }, updateOperation, { new: true }).lean();
+};
+
+/**
+ * Increments the usage of a file identified by file_id.
+ * @param {MongoFile} data - The data to update, must contain file_id and the increment value for usage.
+ * @returns {Promise} A promise that resolves to the updated file document.
+ */
+const updateFileUsage = async (data) => {
+ const { file_id, inc = 1 } = data;
+ const updateOperation = {
+ $inc: { usage: inc },
+ $unset: { expiresAt: '', temp_file_id: '' },
+ };
+ return await File.findOneAndUpdate({ file_id }, updateOperation, { new: true }).lean();
+};
+
+/**
+ * Deletes a file identified by file_id.
+ * @param {string} file_id - The unique identifier of the file to delete.
+ * @returns {Promise} A promise that resolves to the deleted file document or null.
+ */
+const deleteFile = async (file_id) => {
+ return await File.findOneAndDelete({ file_id }).lean();
+};
+
+/**
+ * Deletes a file identified by a filter.
+ * @param {object} filter - The filter criteria to apply.
+ * @returns {Promise} A promise that resolves to the deleted file document or null.
+ */
+const deleteFileByFilter = async (filter) => {
+ return await File.findOneAndDelete(filter).lean();
+};
+
+/**
+ * Deletes multiple files identified by an array of file_ids.
+ * @param {Array} file_ids - The unique identifiers of the files to delete.
+ * @returns {Promise} A promise that resolves to the result of the deletion operation.
+ */
+const deleteFiles = async (file_ids, user) => {
+ let deleteQuery = { file_id: { $in: file_ids } };
+ if (user) {
+ deleteQuery = { user: user };
+ }
+ return await File.deleteMany(deleteQuery);
+};
+
+/**
+ * Batch updates files with new signed URLs in MongoDB
+ *
+ * @param {MongoFile[]} updates - Array of updates in the format { file_id, filepath }
+ * @returns {Promise}
+ */
+async function batchUpdateFiles(updates) {
+ if (!updates || updates.length === 0) {
+ return;
+ }
+
+ const bulkOperations = updates.map((update) => ({
+ updateOne: {
+ filter: { file_id: update.file_id },
+ update: { $set: { filepath: update.filepath } },
+ },
+ }));
+
+ const result = await File.bulkWrite(bulkOperations);
+ logger.info(`Updated ${result.modifiedCount} files with new S3 URLs`);
+}
+
+module.exports = {
+ findFileById,
+ getFiles,
+ getToolFilesByIds,
+ createFile,
+ updateFile,
+ updateFileUsage,
+ deleteFile,
+ deleteFiles,
+ deleteFileByFilter,
+ batchUpdateFiles,
+};
diff --git a/api/models/File.spec.js b/api/models/File.spec.js
new file mode 100644
index 0000000000..2d4282cff7
--- /dev/null
+++ b/api/models/File.spec.js
@@ -0,0 +1,629 @@
+const mongoose = require('mongoose');
+const { v4: uuidv4 } = require('uuid');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { createModels, createMethods } = require('@librechat/data-schemas');
+const {
+ SystemRoles,
+ ResourceType,
+ AccessRoleIds,
+ PrincipalType,
+} = require('librechat-data-provider');
+const { grantPermission } = require('~/server/services/PermissionService');
+const { createAgent } = require('./Agent');
+
+let File;
+let Agent;
+let AclEntry;
+let User;
+let modelsToCleanup = [];
+let methods;
+let getFiles;
+let createFile;
+let seedDefaultRoles;
+
+describe('File Access Control', () => {
+ let mongoServer;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+
+ // Initialize all models
+ const models = createModels(mongoose);
+
+ // Track which models we're adding
+ modelsToCleanup = Object.keys(models);
+
+ // Register models on mongoose.models so methods can access them
+ const dbModels = require('~/db/models');
+ Object.assign(mongoose.models, dbModels);
+
+ File = dbModels.File;
+ Agent = dbModels.Agent;
+ AclEntry = dbModels.AclEntry;
+ User = dbModels.User;
+
+ // Create methods from data-schemas (includes file methods)
+ methods = createMethods(mongoose);
+ getFiles = methods.getFiles;
+ createFile = methods.createFile;
+ seedDefaultRoles = methods.seedDefaultRoles;
+
+ // Seed default roles
+ await seedDefaultRoles();
+ });
+
+ afterAll(async () => {
+ // Clean up all collections before disconnecting
+ const collections = mongoose.connection.collections;
+ for (const key in collections) {
+ await collections[key].deleteMany({});
+ }
+
+ // Clear only the models we added
+ for (const modelName of modelsToCleanup) {
+ if (mongoose.models[modelName]) {
+ delete mongoose.models[modelName];
+ }
+ }
+
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ await File.deleteMany({});
+ await Agent.deleteMany({});
+ await AclEntry.deleteMany({});
+ await User.deleteMany({});
+ // Don't delete AccessRole as they are seeded defaults needed for tests
+ });
+
+ describe('hasAccessToFilesViaAgent', () => {
+ it('should efficiently check access for multiple files at once', async () => {
+ const userId = new mongoose.Types.ObjectId();
+ const authorId = new mongoose.Types.ObjectId();
+ const agentId = uuidv4();
+ const fileIds = [uuidv4(), uuidv4(), uuidv4(), uuidv4()];
+
+ // Create users
+ await User.create({
+ _id: userId,
+ email: 'user@example.com',
+ emailVerified: true,
+ provider: 'local',
+ });
+
+ await User.create({
+ _id: authorId,
+ email: 'author@example.com',
+ emailVerified: true,
+ provider: 'local',
+ });
+
+ // Create files
+ for (const fileId of fileIds) {
+ await createFile({
+ user: authorId,
+ file_id: fileId,
+ filename: `file-${fileId}.txt`,
+ filepath: `/uploads/${fileId}`,
+ });
+ }
+
+ // Create agent with only first two files attached
+ const agent = await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ author: authorId,
+ model: 'gpt-4',
+ provider: 'openai',
+ tool_resources: {
+ file_search: {
+ file_ids: [fileIds[0], fileIds[1]],
+ },
+ },
+ });
+
+ // Grant EDIT permission to user on the agent
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: userId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_EDITOR,
+ grantedBy: authorId,
+ });
+
+ // Check access for all files
+ const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
+ const accessMap = await hasAccessToFilesViaAgent({
+ userId: userId,
+ role: SystemRoles.USER,
+ fileIds,
+ agentId: agent.id, // Use agent.id which is the custom UUID
+ });
+
+ // Should have access only to the first two files
+ expect(accessMap.get(fileIds[0])).toBe(true);
+ expect(accessMap.get(fileIds[1])).toBe(true);
+ expect(accessMap.get(fileIds[2])).toBe(false);
+ expect(accessMap.get(fileIds[3])).toBe(false);
+ });
+
+ it('should grant access to all files when user is the agent author', async () => {
+ const authorId = new mongoose.Types.ObjectId();
+ const agentId = uuidv4();
+ const fileIds = [uuidv4(), uuidv4(), uuidv4()];
+
+ // Create author user
+ await User.create({
+ _id: authorId,
+ email: 'author@example.com',
+ emailVerified: true,
+ provider: 'local',
+ });
+
+ // Create agent
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ author: authorId,
+ model: 'gpt-4',
+ provider: 'openai',
+ tool_resources: {
+ file_search: {
+ file_ids: [fileIds[0]], // Only one file attached
+ },
+ },
+ });
+
+ // Check access as the author
+ const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
+ const accessMap = await hasAccessToFilesViaAgent({
+ userId: authorId,
+ role: SystemRoles.USER,
+ fileIds,
+ agentId,
+ });
+
+ // Author should have access to all files
+ expect(accessMap.get(fileIds[0])).toBe(true);
+ expect(accessMap.get(fileIds[1])).toBe(true);
+ expect(accessMap.get(fileIds[2])).toBe(true);
+ });
+
+ it('should handle non-existent agent gracefully', async () => {
+ const userId = new mongoose.Types.ObjectId();
+ const fileIds = [uuidv4(), uuidv4()];
+
+ // Create user
+ await User.create({
+ _id: userId,
+ email: 'user@example.com',
+ emailVerified: true,
+ provider: 'local',
+ });
+
+ const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
+ const accessMap = await hasAccessToFilesViaAgent({
+ userId: userId,
+ role: SystemRoles.USER,
+ fileIds,
+ agentId: 'non-existent-agent',
+ });
+
+ // Should have no access to any files
+ expect(accessMap.get(fileIds[0])).toBe(false);
+ expect(accessMap.get(fileIds[1])).toBe(false);
+ });
+
+ it('should deny access when user only has VIEW permission and needs access for deletion', async () => {
+ const userId = new mongoose.Types.ObjectId();
+ const authorId = new mongoose.Types.ObjectId();
+ const agentId = uuidv4();
+ const fileIds = [uuidv4(), uuidv4()];
+
+ // Create users
+ await User.create({
+ _id: userId,
+ email: 'user@example.com',
+ emailVerified: true,
+ provider: 'local',
+ });
+
+ await User.create({
+ _id: authorId,
+ email: 'author@example.com',
+ emailVerified: true,
+ provider: 'local',
+ });
+
+ // Create agent with files
+ const agent = await createAgent({
+ id: agentId,
+ name: 'View-Only Agent',
+ author: authorId,
+ model: 'gpt-4',
+ provider: 'openai',
+ tool_resources: {
+ file_search: {
+ file_ids: fileIds,
+ },
+ },
+ });
+
+ // Grant only VIEW permission to user on the agent
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: userId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_VIEWER,
+ grantedBy: authorId,
+ });
+
+ // Check access for files
+ const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
+ const accessMap = await hasAccessToFilesViaAgent({
+ userId: userId,
+ role: SystemRoles.USER,
+ fileIds,
+ agentId,
+ isDelete: true,
+ });
+
+ // Should have no access to any files when only VIEW permission
+ expect(accessMap.get(fileIds[0])).toBe(false);
+ expect(accessMap.get(fileIds[1])).toBe(false);
+ });
+
+ it('should grant access when user has VIEW permission', async () => {
+ const userId = new mongoose.Types.ObjectId();
+ const authorId = new mongoose.Types.ObjectId();
+ const agentId = uuidv4();
+ const fileIds = [uuidv4(), uuidv4()];
+
+ // Create users
+ await User.create({
+ _id: userId,
+ email: 'user@example.com',
+ emailVerified: true,
+ provider: 'local',
+ });
+
+ await User.create({
+ _id: authorId,
+ email: 'author@example.com',
+ emailVerified: true,
+ provider: 'local',
+ });
+
+ // Create agent with files
+ const agent = await createAgent({
+ id: agentId,
+ name: 'View-Only Agent',
+ author: authorId,
+ model: 'gpt-4',
+ provider: 'openai',
+ tool_resources: {
+ file_search: {
+ file_ids: fileIds,
+ },
+ },
+ });
+
+ // Grant only VIEW permission to user on the agent
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: userId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_VIEWER,
+ grantedBy: authorId,
+ });
+
+ // Check access for files
+ const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
+ const accessMap = await hasAccessToFilesViaAgent({
+ userId: userId,
+ role: SystemRoles.USER,
+ fileIds,
+ agentId,
+ });
+
+ expect(accessMap.get(fileIds[0])).toBe(true);
+ expect(accessMap.get(fileIds[1])).toBe(true);
+ });
+ });
+
+ describe('getFiles with agent access control', () => {
+ test('should return files owned by user and files accessible through agent', async () => {
+ const authorId = new mongoose.Types.ObjectId();
+ const userId = new mongoose.Types.ObjectId();
+ const agentId = `agent_${uuidv4()}`;
+ const ownedFileId = `file_${uuidv4()}`;
+ const sharedFileId = `file_${uuidv4()}`;
+ const inaccessibleFileId = `file_${uuidv4()}`;
+
+ // Create users
+ await User.create({
+ _id: userId,
+ email: 'user@example.com',
+ emailVerified: true,
+ provider: 'local',
+ });
+
+ await User.create({
+ _id: authorId,
+ email: 'author@example.com',
+ emailVerified: true,
+ provider: 'local',
+ });
+
+ // Create agent with shared file
+ const agent = await createAgent({
+ id: agentId,
+ name: 'Shared Agent',
+ provider: 'test',
+ model: 'test-model',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: [sharedFileId],
+ },
+ },
+ });
+
+ // Grant EDIT permission to user on the agent
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: userId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_EDITOR,
+ grantedBy: authorId,
+ });
+
+ // Create files
+ await createFile({
+ file_id: ownedFileId,
+ user: userId,
+ filename: 'owned.txt',
+ filepath: '/uploads/owned.txt',
+ type: 'text/plain',
+ bytes: 100,
+ });
+
+ await createFile({
+ file_id: sharedFileId,
+ user: authorId,
+ filename: 'shared.txt',
+ filepath: '/uploads/shared.txt',
+ type: 'text/plain',
+ bytes: 200,
+ embedded: true,
+ });
+
+ await createFile({
+ file_id: inaccessibleFileId,
+ user: authorId,
+ filename: 'inaccessible.txt',
+ filepath: '/uploads/inaccessible.txt',
+ type: 'text/plain',
+ bytes: 300,
+ });
+
+ // Get all files first
+ const allFiles = await getFiles(
+ { file_id: { $in: [ownedFileId, sharedFileId, inaccessibleFileId] } },
+ null,
+ { text: 0 },
+ );
+
+ // Then filter by access control
+ const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
+ const files = await filterFilesByAgentAccess({
+ files: allFiles,
+ userId: userId,
+ role: SystemRoles.USER,
+ agentId,
+ });
+
+ expect(files).toHaveLength(2);
+ expect(files.map((f) => f.file_id)).toContain(ownedFileId);
+ expect(files.map((f) => f.file_id)).toContain(sharedFileId);
+ expect(files.map((f) => f.file_id)).not.toContain(inaccessibleFileId);
+ });
+
+ test('should return all files when no userId/agentId provided', async () => {
+ const userId = new mongoose.Types.ObjectId();
+ const fileId1 = `file_${uuidv4()}`;
+ const fileId2 = `file_${uuidv4()}`;
+
+ await createFile({
+ file_id: fileId1,
+ user: userId,
+ filename: 'file1.txt',
+ filepath: '/uploads/file1.txt',
+ type: 'text/plain',
+ bytes: 100,
+ });
+
+ await createFile({
+ file_id: fileId2,
+ user: new mongoose.Types.ObjectId(),
+ filename: 'file2.txt',
+ filepath: '/uploads/file2.txt',
+ type: 'text/plain',
+ bytes: 200,
+ });
+
+ const files = await getFiles({ file_id: { $in: [fileId1, fileId2] } });
+ expect(files).toHaveLength(2);
+ });
+ });
+
+ describe('Role-based file permissions', () => {
+ it('should optimize permission checks when role is provided', async () => {
+ const userId = new mongoose.Types.ObjectId();
+ const authorId = new mongoose.Types.ObjectId();
+ const agentId = uuidv4();
+ const fileIds = [uuidv4(), uuidv4()];
+
+ // Create users
+ await User.create({
+ _id: userId,
+ email: 'user@example.com',
+ emailVerified: true,
+ provider: 'local',
+ role: 'ADMIN', // User has ADMIN role
+ });
+
+ await User.create({
+ _id: authorId,
+ email: 'author@example.com',
+ emailVerified: true,
+ provider: 'local',
+ });
+
+ // Create files
+ for (const fileId of fileIds) {
+ await createFile({
+ file_id: fileId,
+ user: authorId,
+ filename: `${fileId}.txt`,
+ filepath: `/uploads/${fileId}.txt`,
+ type: 'text/plain',
+ bytes: 100,
+ });
+ }
+
+ // Create agent with files
+ const agent = await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ author: authorId,
+ model: 'gpt-4',
+ provider: 'openai',
+ tool_resources: {
+ file_search: {
+ file_ids: fileIds,
+ },
+ },
+ });
+
+ // Grant permission to ADMIN role
+ await grantPermission({
+ principalType: PrincipalType.ROLE,
+ principalId: 'ADMIN',
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_EDITOR,
+ grantedBy: authorId,
+ });
+
+ // Check access with role provided (should avoid DB query)
+ const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
+ const accessMapWithRole = await hasAccessToFilesViaAgent({
+ userId: userId,
+ role: 'ADMIN',
+ fileIds,
+ agentId: agent.id,
+ });
+
+ // User should have access through their ADMIN role
+ expect(accessMapWithRole.get(fileIds[0])).toBe(true);
+ expect(accessMapWithRole.get(fileIds[1])).toBe(true);
+
+ // Check access without role (will query DB to get user's role)
+ const accessMapWithoutRole = await hasAccessToFilesViaAgent({
+ userId: userId,
+ fileIds,
+ agentId: agent.id,
+ });
+
+ // Should have same result
+ expect(accessMapWithoutRole.get(fileIds[0])).toBe(true);
+ expect(accessMapWithoutRole.get(fileIds[1])).toBe(true);
+ });
+
+ it('should deny access when user role changes', async () => {
+ const userId = new mongoose.Types.ObjectId();
+ const authorId = new mongoose.Types.ObjectId();
+ const agentId = uuidv4();
+ const fileId = uuidv4();
+
+ // Create users
+ await User.create({
+ _id: userId,
+ email: 'user@example.com',
+ emailVerified: true,
+ provider: 'local',
+ role: 'EDITOR',
+ });
+
+ await User.create({
+ _id: authorId,
+ email: 'author@example.com',
+ emailVerified: true,
+ provider: 'local',
+ });
+
+ // Create file
+ await createFile({
+ file_id: fileId,
+ user: authorId,
+ filename: 'test.txt',
+ filepath: '/uploads/test.txt',
+ type: 'text/plain',
+ bytes: 100,
+ });
+
+ // Create agent
+ const agent = await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ author: authorId,
+ model: 'gpt-4',
+ provider: 'openai',
+ tool_resources: {
+ file_search: {
+ file_ids: [fileId],
+ },
+ },
+ });
+
+ // Grant permission to EDITOR role only
+ await grantPermission({
+ principalType: PrincipalType.ROLE,
+ principalId: 'EDITOR',
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_EDITOR,
+ grantedBy: authorId,
+ });
+
+ const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
+
+ // Check with EDITOR role - should have access
+ const accessAsEditor = await hasAccessToFilesViaAgent({
+ userId: userId,
+ role: 'EDITOR',
+ fileIds: [fileId],
+ agentId: agent.id,
+ });
+ expect(accessAsEditor.get(fileId)).toBe(true);
+
+ // Simulate role change to USER - should lose access
+ const accessAsUser = await hasAccessToFilesViaAgent({
+ userId: userId,
+ role: SystemRoles.USER,
+ fileIds: [fileId],
+ agentId: agent.id,
+ });
+ expect(accessAsUser.get(fileId)).toBe(false);
+ });
+ });
+});
diff --git a/api/models/Message.js b/api/models/Message.js
index e6657c060a..8fe04f6f54 100644
--- a/api/models/Message.js
+++ b/api/models/Message.js
@@ -1,75 +1,372 @@
-const mongoose = require('mongoose');
+const { z } = require('zod');
+const { logger } = require('@librechat/data-schemas');
+const { createTempChatExpirationDate } = require('@librechat/api');
+const { Message } = require('~/db/models');
-const messageSchema = mongoose.Schema({
- id: {
- type: String,
- unique: true,
- required: true
- },
- conversationId: {
- type: String,
- required: true
- },
- conversationSignature: {
- type: String,
- // required: true
- },
- clientId: {
- type: String,
- },
- invocationId: {
- type: String,
- },
- parentMessageId: {
- type: String,
- // required: true
- },
- sender: {
- type: String,
- required: true
- },
- text: {
- type: String,
- required: true
- },
- created: {
- type: Date,
- default: Date.now
+const idSchema = z.string().uuid();
+
+/**
+ * Saves a message in the database.
+ *
+ * @async
+ * @function saveMessage
+ * @param {ServerRequest} req - The request object containing user information.
+ * @param {Object} params - The message data object.
+ * @param {string} params.endpoint - The endpoint where the message originated.
+ * @param {string} params.iconURL - The URL of the sender's icon.
+ * @param {string} params.messageId - The unique identifier for the message.
+ * @param {string} params.newMessageId - The new unique identifier for the message (if applicable).
+ * @param {string} params.conversationId - The identifier of the conversation.
+ * @param {string} [params.parentMessageId] - The identifier of the parent message, if any.
+ * @param {string} params.sender - The identifier of the sender.
+ * @param {string} params.text - The text content of the message.
+ * @param {boolean} params.isCreatedByUser - Indicates if the message was created by the user.
+ * @param {string} [params.error] - Any error associated with the message.
+ * @param {boolean} [params.unfinished] - Indicates if the message is unfinished.
+ * @param {Object[]} [params.files] - An array of files associated with the message.
+ * @param {string} [params.finish_reason] - Reason for finishing the message.
+ * @param {number} [params.tokenCount] - The number of tokens in the message.
+ * @param {string} [params.plugin] - Plugin associated with the message.
+ * @param {string[]} [params.plugins] - An array of plugins associated with the message.
+ * @param {string} [params.model] - The model used to generate the message.
+ * @param {Object} [metadata] - Additional metadata for this operation
+ * @param {string} [metadata.context] - The context of the operation
+ * @returns {Promise} The updated or newly inserted message document.
+ * @throws {Error} If there is an error in saving the message.
+ */
+async function saveMessage(req, params, metadata) {
+ if (!req?.user?.id) {
+ throw new Error('User not authenticated');
}
-});
-const Message = mongoose.models.Message || mongoose.model('Message', messageSchema);
+ const validConvoId = idSchema.safeParse(params.conversationId);
+ if (!validConvoId.success) {
+ logger.warn(`Invalid conversation ID: ${params.conversationId}`);
+ logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
+ logger.info(`---Invalid conversation ID Params: ${JSON.stringify(params, null, 2)}`);
+ return;
+ }
+
+ try {
+ const update = {
+ ...params,
+ user: req.user.id,
+ messageId: params.newMessageId || params.messageId,
+ };
+
+ if (req?.body?.isTemporary) {
+ try {
+ const appConfig = req.config;
+ update.expiredAt = createTempChatExpirationDate(appConfig?.interfaceConfig);
+ } catch (err) {
+ logger.error('Error creating temporary chat expiration date:', err);
+ logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
+ update.expiredAt = null;
+ }
+ } else {
+ 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,
+ { upsert: true, new: true },
+ );
+
+ return message.toObject();
+ } catch (err) {
+ logger.error('Error saving message:', err);
+ logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
+
+ // Check if this is a duplicate key error (MongoDB error code 11000)
+ if (err.code === 11000 && err.message.includes('duplicate key error')) {
+ // Log the duplicate key error but don't crash the application
+ logger.warn(`Duplicate messageId detected: ${params.messageId}. Continuing execution.`);
+
+ try {
+ // Try to find the existing message with this ID
+ const existingMessage = await Message.findOne({
+ messageId: params.messageId,
+ user: req.user.id,
+ });
+
+ // If we found it, return it
+ if (existingMessage) {
+ return existingMessage.toObject();
+ }
+
+ // If we can't find it (unlikely but possible in race conditions)
+ return {
+ ...params,
+ messageId: params.messageId,
+ user: req.user.id,
+ };
+ } 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}`,
+ );
+ return {
+ ...params,
+ messageId: params.messageId,
+ user: req.user.id,
+ };
+ }
+ }
+
+ throw err; // Re-throw other errors
+ }
+}
+
+/**
+ * Saves multiple messages in the database in bulk.
+ *
+ * @async
+ * @function bulkSaveMessages
+ * @param {Object[]} messages - An array of message objects to save.
+ * @param {boolean} [overrideTimestamp=false] - Indicates whether to override the timestamps of the messages. Defaults to false.
+ * @returns {Promise} The result of the bulk write operation.
+ * @throws {Error} If there is an error in saving messages in bulk.
+ */
+async function bulkSaveMessages(messages, overrideTimestamp = false) {
+ try {
+ const bulkOps = messages.map((message) => ({
+ updateOne: {
+ filter: { messageId: message.messageId },
+ update: message,
+ timestamps: !overrideTimestamp,
+ upsert: true,
+ },
+ }));
+ const result = await Message.bulkWrite(bulkOps);
+ return result;
+ } catch (err) {
+ logger.error('Error saving messages in bulk:', err);
+ throw err;
+ }
+}
+
+/**
+ * Records a message in the database.
+ *
+ * @async
+ * @function recordMessage
+ * @param {Object} params - The message data object.
+ * @param {string} params.user - The identifier of the user.
+ * @param {string} params.endpoint - The endpoint where the message originated.
+ * @param {string} params.messageId - The unique identifier for the message.
+ * @param {string} params.conversationId - The identifier of the conversation.
+ * @param {string} [params.parentMessageId] - The identifier of the parent message, if any.
+ * @param {Partial} rest - Any additional properties from the TMessage typedef not explicitly listed.
+ * @returns {Promise} The updated or newly inserted message document.
+ * @throws {Error} If there is an error in saving the message.
+ */
+async function recordMessage({
+ user,
+ endpoint,
+ messageId,
+ conversationId,
+ parentMessageId,
+ ...rest
+}) {
+ try {
+ // No parsing of convoId as may use threadId
+ const message = {
+ user,
+ endpoint,
+ messageId,
+ conversationId,
+ parentMessageId,
+ ...rest,
+ };
+
+ return await Message.findOneAndUpdate({ user, messageId }, message, {
+ upsert: true,
+ new: true,
+ });
+ } catch (err) {
+ logger.error('Error recording message:', err);
+ throw err;
+ }
+}
+
+/**
+ * Updates the text of a message.
+ *
+ * @async
+ * @function updateMessageText
+ * @param {Object} params - The update data object.
+ * @param {Object} req - The request object.
+ * @param {string} params.messageId - The unique identifier for the message.
+ * @param {string} params.text - The new text content of the message.
+ * @returns {Promise}
+ * @throws {Error} If there is an error in updating the message text.
+ */
+async function updateMessageText(req, { messageId, text }) {
+ try {
+ await Message.updateOne({ messageId, user: req.user.id }, { text });
+ } catch (err) {
+ logger.error('Error updating message text:', err);
+ throw err;
+ }
+}
+
+/**
+ * Updates a message.
+ *
+ * @async
+ * @function updateMessage
+ * @param {Object} req - The request object.
+ * @param {Object} message - The message object containing update data.
+ * @param {string} message.messageId - The unique identifier for the message.
+ * @param {string} [message.text] - The new text content of the message.
+ * @param {Object[]} [message.files] - The files associated with the message.
+ * @param {boolean} [message.isCreatedByUser] - Indicates if the message was created by the user.
+ * @param {string} [message.sender] - The identifier of the sender.
+ * @param {number} [message.tokenCount] - The number of tokens in the message.
+ * @param {Object} [metadata] - The operation metadata
+ * @param {string} [metadata.context] - The operation metadata
+ * @returns {Promise} The updated message document.
+ * @throws {Error} If there is an error in updating the message or if the message is not found.
+ */
+async function updateMessage(req, message, metadata) {
+ try {
+ const { messageId, ...update } = message;
+ const updatedMessage = await Message.findOneAndUpdate(
+ { messageId, user: req.user.id },
+ update,
+ {
+ new: true,
+ },
+ );
+
+ if (!updatedMessage) {
+ throw new Error('Message not found or user not authorized.');
+ }
+
+ return {
+ messageId: updatedMessage.messageId,
+ conversationId: updatedMessage.conversationId,
+ parentMessageId: updatedMessage.parentMessageId,
+ sender: updatedMessage.sender,
+ text: updatedMessage.text,
+ isCreatedByUser: updatedMessage.isCreatedByUser,
+ tokenCount: updatedMessage.tokenCount,
+ feedback: updatedMessage.feedback,
+ };
+ } catch (err) {
+ logger.error('Error updating message:', err);
+ if (metadata && metadata?.context) {
+ logger.info(`---\`updateMessage\` context: ${metadata.context}`);
+ }
+ throw err;
+ }
+}
+
+/**
+ * Deletes messages in a conversation since a specific message.
+ *
+ * @async
+ * @function deleteMessagesSince
+ * @param {Object} params - The parameters object.
+ * @param {Object} req - The request object.
+ * @param {string} params.messageId - The unique identifier for the message.
+ * @param {string} params.conversationId - The identifier of the conversation.
+ * @returns {Promise} The number of deleted messages.
+ * @throws {Error} If there is an error in deleting messages.
+ */
+async function deleteMessagesSince(req, { messageId, conversationId }) {
+ try {
+ const message = await Message.findOne({ messageId, user: req.user.id }).lean();
+
+ if (message) {
+ const query = Message.find({ conversationId, user: req.user.id });
+ return await query.deleteMany({
+ createdAt: { $gt: message.createdAt },
+ });
+ }
+ return undefined;
+ } catch (err) {
+ logger.error('Error deleting messages:', err);
+ throw err;
+ }
+}
+
+/**
+ * Retrieves messages from the database.
+ * @async
+ * @function getMessages
+ * @param {Record} filter - The filter criteria.
+ * @param {string | undefined} [select] - The fields to select.
+ * @returns {Promise} The messages that match the filter criteria.
+ * @throws {Error} If there is an error in retrieving messages.
+ */
+async function getMessages(filter, select) {
+ try {
+ if (select) {
+ return await Message.find(filter).select(select).sort({ createdAt: 1 }).lean();
+ }
+
+ return await Message.find(filter).sort({ createdAt: 1 }).lean();
+ } catch (err) {
+ logger.error('Error getting messages:', err);
+ throw err;
+ }
+}
+
+/**
+ * Retrieves a single message from the database.
+ * @async
+ * @function getMessage
+ * @param {{ user: string, messageId: string }} params - The search parameters
+ * @returns {Promise} The message that matches the criteria or null if not found
+ * @throws {Error} If there is an error in retrieving the message
+ */
+async function getMessage({ user, messageId }) {
+ try {
+ return await Message.findOne({
+ user,
+ messageId,
+ }).lean();
+ } catch (err) {
+ logger.error('Error getting message:', err);
+ throw err;
+ }
+}
+
+/**
+ * Deletes messages from the database.
+ *
+ * @async
+ * @function deleteMessages
+ * @param {import('mongoose').FilterQuery} filter - The filter criteria to find messages to delete.
+ * @returns {Promise} The metadata with count of deleted messages.
+ * @throws {Error} If there is an error in deleting messages.
+ */
+async function deleteMessages(filter) {
+ try {
+ return await Message.deleteMany(filter);
+ } catch (err) {
+ logger.error('Error deleting messages:', err);
+ throw err;
+ }
+}
module.exports = {
- saveMessage: async ({ id, conversationId, parentMessageId, sender, text }) => {
- try {
- await Message.create({
- id,
- conversationId,
- parentMessageId,
- sender,
- text
- });
- return { id, conversationId, parentMessageId, sender, text };
- } catch (error) {
- console.error(error);
- return { message: 'Error saving message' };
- }
- },
- getMessages: async (filter) => {
- try {
- return await Message.find(filter).exec()
- } catch (error) {
- console.error(error);
- return { message: 'Error getting messages' };
- }
- },
- deleteMessages: async (filter) => {
- try {
- return await Message.deleteMany(filter).exec()
- } catch (error) {
- console.error(error);
- return { message: 'Error deleting messages' };
- }
- }
-}
\ No newline at end of file
+ saveMessage,
+ bulkSaveMessages,
+ recordMessage,
+ updateMessageText,
+ updateMessage,
+ deleteMessagesSince,
+ getMessages,
+ getMessage,
+ deleteMessages,
+};
diff --git a/api/models/Message.spec.js b/api/models/Message.spec.js
new file mode 100644
index 0000000000..2dab6b2866
--- /dev/null
+++ b/api/models/Message.spec.js
@@ -0,0 +1,576 @@
+const mongoose = require('mongoose');
+const { v4: uuidv4 } = require('uuid');
+const { messageSchema } = require('@librechat/data-schemas');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+
+const {
+ saveMessage,
+ getMessages,
+ updateMessage,
+ deleteMessages,
+ bulkSaveMessages,
+ updateMessageText,
+ deleteMessagesSince,
+} = require('./Message');
+
+jest.mock('~/server/services/Config/app');
+
+/**
+ * @type {import('mongoose').Model}
+ */
+let Message;
+
+describe('Message Operations', () => {
+ let mongoServer;
+ let mockReq;
+ let mockMessageData;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ Message = mongoose.models.Message || mongoose.model('Message', messageSchema);
+ await mongoose.connect(mongoUri);
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ // Clear database
+ await Message.deleteMany({});
+
+ mockReq = {
+ user: { id: 'user123' },
+ config: {
+ interfaceConfig: {
+ temporaryChatRetention: 24, // Default 24 hours
+ },
+ },
+ };
+
+ mockMessageData = {
+ messageId: 'msg123',
+ conversationId: uuidv4(),
+ text: 'Hello, world!',
+ user: 'user123',
+ };
+ });
+
+ describe('saveMessage', () => {
+ it('should save a message for an authenticated user', async () => {
+ const result = await saveMessage(mockReq, mockMessageData);
+
+ expect(result.messageId).toBe('msg123');
+ expect(result.user).toBe('user123');
+ expect(result.text).toBe('Hello, world!');
+
+ // Verify the message was actually saved to the database
+ const savedMessage = await Message.findOne({ messageId: 'msg123', user: 'user123' });
+ expect(savedMessage).toBeTruthy();
+ expect(savedMessage.text).toBe('Hello, world!');
+ });
+
+ it('should throw an error for unauthenticated user', async () => {
+ mockReq.user = null;
+ await expect(saveMessage(mockReq, mockMessageData)).rejects.toThrow('User not authenticated');
+ });
+
+ it('should handle invalid conversation ID gracefully', async () => {
+ mockMessageData.conversationId = 'invalid-id';
+ const result = await saveMessage(mockReq, mockMessageData);
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('updateMessageText', () => {
+ it('should update message text for the authenticated user', async () => {
+ // First save a message
+ await saveMessage(mockReq, mockMessageData);
+
+ // Then update it
+ await updateMessageText(mockReq, { messageId: 'msg123', text: 'Updated text' });
+
+ // Verify the update
+ const updatedMessage = await Message.findOne({ messageId: 'msg123', user: 'user123' });
+ expect(updatedMessage.text).toBe('Updated text');
+ });
+ });
+
+ describe('updateMessage', () => {
+ it('should update a message for the authenticated user', async () => {
+ // First save a message
+ await saveMessage(mockReq, mockMessageData);
+
+ const result = await updateMessage(mockReq, { messageId: 'msg123', text: 'Updated text' });
+
+ expect(result.messageId).toBe('msg123');
+ expect(result.text).toBe('Updated text');
+
+ // Verify in database
+ const updatedMessage = await Message.findOne({ messageId: 'msg123', user: 'user123' });
+ expect(updatedMessage.text).toBe('Updated text');
+ });
+
+ it('should throw an error if message is not found', async () => {
+ await expect(
+ updateMessage(mockReq, { messageId: 'nonexistent', text: 'Test' }),
+ ).rejects.toThrow('Message not found or user not authorized.');
+ });
+ });
+
+ describe('deleteMessagesSince', () => {
+ it('should delete messages only for the authenticated user', async () => {
+ const conversationId = uuidv4();
+
+ // Create multiple messages in the same conversation
+ await saveMessage(mockReq, {
+ messageId: 'msg1',
+ conversationId,
+ text: 'First message',
+ user: 'user123',
+ });
+
+ await saveMessage(mockReq, {
+ messageId: 'msg2',
+ conversationId,
+ text: 'Second message',
+ user: 'user123',
+ });
+
+ await saveMessage(mockReq, {
+ messageId: 'msg3',
+ conversationId,
+ text: 'Third message',
+ user: 'user123',
+ });
+
+ // Delete messages since message2 (this should only delete messages created AFTER msg2)
+ await deleteMessagesSince(mockReq, {
+ messageId: 'msg2',
+ conversationId,
+ });
+
+ // Verify msg1 and msg2 remain, msg3 is deleted
+ const remainingMessages = await Message.find({ conversationId, user: 'user123' });
+ expect(remainingMessages).toHaveLength(2);
+ expect(remainingMessages.map((m) => m.messageId)).toContain('msg1');
+ expect(remainingMessages.map((m) => m.messageId)).toContain('msg2');
+ expect(remainingMessages.map((m) => m.messageId)).not.toContain('msg3');
+ });
+
+ it('should return undefined if no message is found', async () => {
+ const result = await deleteMessagesSince(mockReq, {
+ messageId: 'nonexistent',
+ conversationId: 'convo123',
+ });
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('getMessages', () => {
+ it('should retrieve messages with the correct filter', async () => {
+ const conversationId = uuidv4();
+
+ // Save some messages
+ await saveMessage(mockReq, {
+ messageId: 'msg1',
+ conversationId,
+ text: 'First message',
+ user: 'user123',
+ });
+
+ await saveMessage(mockReq, {
+ messageId: 'msg2',
+ conversationId,
+ text: 'Second message',
+ user: 'user123',
+ });
+
+ const messages = await getMessages({ conversationId });
+ expect(messages).toHaveLength(2);
+ expect(messages[0].text).toBe('First message');
+ expect(messages[1].text).toBe('Second message');
+ });
+ });
+
+ describe('deleteMessages', () => {
+ it('should delete messages with the correct filter', async () => {
+ // Save some messages for different users
+ await saveMessage(mockReq, mockMessageData);
+ await saveMessage(
+ { user: { id: 'user456' } },
+ {
+ messageId: 'msg456',
+ conversationId: uuidv4(),
+ text: 'Other user message',
+ user: 'user456',
+ },
+ );
+
+ await deleteMessages({ user: 'user123' });
+
+ // Verify only user123's messages were deleted
+ const user123Messages = await Message.find({ user: 'user123' });
+ const user456Messages = await Message.find({ user: 'user456' });
+
+ expect(user123Messages).toHaveLength(0);
+ expect(user456Messages).toHaveLength(1);
+ });
+ });
+
+ describe('Conversation Hijacking Prevention', () => {
+ it("should not allow editing a message in another user's conversation", async () => {
+ const attackerReq = { user: { id: 'attacker123' } };
+ const victimConversationId = uuidv4();
+ const victimMessageId = 'victim-msg-123';
+
+ // First, save a message as the victim (but we'll try to edit as attacker)
+ const victimReq = { user: { id: 'victim123' } };
+ await saveMessage(victimReq, {
+ messageId: victimMessageId,
+ conversationId: victimConversationId,
+ text: 'Victim message',
+ user: 'victim123',
+ });
+
+ // Attacker tries to edit the victim's message
+ await expect(
+ updateMessage(attackerReq, {
+ messageId: victimMessageId,
+ conversationId: victimConversationId,
+ text: 'Hacked message',
+ }),
+ ).rejects.toThrow('Message not found or user not authorized.');
+
+ // Verify the original message is unchanged
+ const originalMessage = await Message.findOne({
+ messageId: victimMessageId,
+ user: 'victim123',
+ });
+ expect(originalMessage.text).toBe('Victim message');
+ });
+
+ it("should not allow deleting messages from another user's conversation", async () => {
+ const attackerReq = { user: { id: 'attacker123' } };
+ const victimConversationId = uuidv4();
+ const victimMessageId = 'victim-msg-123';
+
+ // Save a message as the victim
+ const victimReq = { user: { id: 'victim123' } };
+ await saveMessage(victimReq, {
+ messageId: victimMessageId,
+ conversationId: victimConversationId,
+ text: 'Victim message',
+ user: 'victim123',
+ });
+
+ // Attacker tries to delete from victim's conversation
+ const result = await deleteMessagesSince(attackerReq, {
+ messageId: victimMessageId,
+ conversationId: victimConversationId,
+ });
+
+ expect(result).toBeUndefined();
+
+ // Verify the victim's message still exists
+ const victimMessage = await Message.findOne({
+ messageId: victimMessageId,
+ user: 'victim123',
+ });
+ expect(victimMessage).toBeTruthy();
+ expect(victimMessage.text).toBe('Victim message');
+ });
+
+ it("should not allow inserting a new message into another user's conversation", async () => {
+ const attackerReq = { user: { id: 'attacker123' } };
+ const victimConversationId = uuidv4();
+
+ // Attacker tries to save a message - this should succeed but with attacker's user ID
+ const result = await saveMessage(attackerReq, {
+ conversationId: victimConversationId,
+ text: 'Inserted malicious message',
+ messageId: 'new-msg-123',
+ user: 'attacker123',
+ });
+
+ expect(result).toBeTruthy();
+ expect(result.user).toBe('attacker123');
+
+ // Verify the message was saved with the attacker's user ID, not as an anonymous message
+ const savedMessage = await Message.findOne({ messageId: 'new-msg-123' });
+ expect(savedMessage.user).toBe('attacker123');
+ expect(savedMessage.conversationId).toBe(victimConversationId);
+ });
+
+ it('should allow retrieving messages from any conversation', async () => {
+ const victimConversationId = uuidv4();
+
+ // Save a message in the victim's conversation
+ const victimReq = { user: { id: 'victim123' } };
+ await saveMessage(victimReq, {
+ messageId: 'victim-msg',
+ conversationId: victimConversationId,
+ text: 'Victim message',
+ user: 'victim123',
+ });
+
+ // Anyone should be able to retrieve messages by conversation ID
+ const messages = await getMessages({ conversationId: victimConversationId });
+ expect(messages).toHaveLength(1);
+ expect(messages[0].text).toBe('Victim message');
+ });
+ });
+
+ describe('isTemporary message handling', () => {
+ beforeEach(() => {
+ // Reset mocks before each test
+ jest.clearAllMocks();
+ });
+
+ it('should save a message with expiredAt when isTemporary is true', async () => {
+ // Mock app config with 24 hour retention
+ mockReq.config.interfaceConfig.temporaryChatRetention = 24;
+
+ mockReq.body = { isTemporary: true };
+
+ const beforeSave = new Date();
+ const result = await saveMessage(mockReq, mockMessageData);
+ const afterSave = new Date();
+
+ expect(result.messageId).toBe('msg123');
+ expect(result.expiredAt).toBeDefined();
+ expect(result.expiredAt).toBeInstanceOf(Date);
+
+ // Verify expiredAt is approximately 24 hours in the future
+ const expectedExpirationTime = new Date(beforeSave.getTime() + 24 * 60 * 60 * 1000);
+ const actualExpirationTime = new Date(result.expiredAt);
+
+ expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
+ expectedExpirationTime.getTime() - 1000,
+ );
+ expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
+ new Date(afterSave.getTime() + 24 * 60 * 60 * 1000 + 1000).getTime(),
+ );
+ });
+
+ it('should save a message without expiredAt when isTemporary is false', async () => {
+ mockReq.body = { isTemporary: false };
+
+ const result = await saveMessage(mockReq, mockMessageData);
+
+ expect(result.messageId).toBe('msg123');
+ expect(result.expiredAt).toBeNull();
+ });
+
+ it('should save a message without expiredAt when isTemporary is not provided', async () => {
+ // No isTemporary in body
+ mockReq.body = {};
+
+ const result = await saveMessage(mockReq, mockMessageData);
+
+ expect(result.messageId).toBe('msg123');
+ expect(result.expiredAt).toBeNull();
+ });
+
+ it('should use custom retention period from config', async () => {
+ // Mock app config with 48 hour retention
+ mockReq.config.interfaceConfig.temporaryChatRetention = 48;
+
+ mockReq.body = { isTemporary: true };
+
+ const beforeSave = new Date();
+ const result = await saveMessage(mockReq, mockMessageData);
+
+ expect(result.expiredAt).toBeDefined();
+
+ // Verify expiredAt is approximately 48 hours in the future
+ const expectedExpirationTime = new Date(beforeSave.getTime() + 48 * 60 * 60 * 1000);
+ const actualExpirationTime = new Date(result.expiredAt);
+
+ expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
+ expectedExpirationTime.getTime() - 1000,
+ );
+ expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
+ expectedExpirationTime.getTime() + 1000,
+ );
+ });
+
+ it('should handle minimum retention period (1 hour)', async () => {
+ // Mock app config with less than minimum retention
+ mockReq.config.interfaceConfig.temporaryChatRetention = 0.5; // Half hour - should be clamped to 1 hour
+
+ mockReq.body = { isTemporary: true };
+
+ const beforeSave = new Date();
+ const result = await saveMessage(mockReq, mockMessageData);
+
+ expect(result.expiredAt).toBeDefined();
+
+ // Verify expiredAt is approximately 1 hour in the future (minimum)
+ const expectedExpirationTime = new Date(beforeSave.getTime() + 1 * 60 * 60 * 1000);
+ const actualExpirationTime = new Date(result.expiredAt);
+
+ expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
+ expectedExpirationTime.getTime() - 1000,
+ );
+ expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
+ expectedExpirationTime.getTime() + 1000,
+ );
+ });
+
+ it('should handle maximum retention period (8760 hours)', async () => {
+ // Mock app config with more than maximum retention
+ mockReq.config.interfaceConfig.temporaryChatRetention = 10000; // Should be clamped to 8760 hours
+
+ mockReq.body = { isTemporary: true };
+
+ const beforeSave = new Date();
+ const result = await saveMessage(mockReq, mockMessageData);
+
+ expect(result.expiredAt).toBeDefined();
+
+ // Verify expiredAt is approximately 8760 hours (1 year) in the future
+ const expectedExpirationTime = new Date(beforeSave.getTime() + 8760 * 60 * 60 * 1000);
+ const actualExpirationTime = new Date(result.expiredAt);
+
+ expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
+ expectedExpirationTime.getTime() - 1000,
+ );
+ expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
+ expectedExpirationTime.getTime() + 1000,
+ );
+ });
+
+ it('should handle missing config gracefully', async () => {
+ // Simulate missing config - should use default retention period
+ delete mockReq.config;
+
+ mockReq.body = { isTemporary: true };
+
+ const beforeSave = new Date();
+ const result = await saveMessage(mockReq, mockMessageData);
+ const afterSave = new Date();
+
+ // Should still save the message with default retention period (30 days)
+ expect(result.messageId).toBe('msg123');
+ expect(result.expiredAt).toBeDefined();
+ expect(result.expiredAt).toBeInstanceOf(Date);
+
+ // Verify expiredAt is approximately 30 days in the future (720 hours)
+ const expectedExpirationTime = new Date(beforeSave.getTime() + 720 * 60 * 60 * 1000);
+ const actualExpirationTime = new Date(result.expiredAt);
+
+ expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
+ expectedExpirationTime.getTime() - 1000,
+ );
+ expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
+ new Date(afterSave.getTime() + 720 * 60 * 60 * 1000 + 1000).getTime(),
+ );
+ });
+
+ it('should use default retention when config is not provided', async () => {
+ // Mock getAppConfig to return empty config
+ mockReq.config = {}; // Empty config
+
+ mockReq.body = { isTemporary: true };
+
+ const beforeSave = new Date();
+ const result = await saveMessage(mockReq, mockMessageData);
+
+ expect(result.expiredAt).toBeDefined();
+
+ // Default retention is 30 days (720 hours)
+ const expectedExpirationTime = new Date(beforeSave.getTime() + 30 * 24 * 60 * 60 * 1000);
+ const actualExpirationTime = new Date(result.expiredAt);
+
+ expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
+ expectedExpirationTime.getTime() - 1000,
+ );
+ expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
+ expectedExpirationTime.getTime() + 1000,
+ );
+ });
+
+ it('should not update expiredAt on message update', async () => {
+ // First save a temporary message
+ mockReq.config.interfaceConfig.temporaryChatRetention = 24;
+
+ mockReq.body = { isTemporary: true };
+ const savedMessage = await saveMessage(mockReq, mockMessageData);
+ const originalExpiredAt = savedMessage.expiredAt;
+
+ // Now update the message without isTemporary flag
+ mockReq.body = {};
+ const updatedMessage = await updateMessage(mockReq, {
+ messageId: 'msg123',
+ text: 'Updated text',
+ });
+
+ // expiredAt should not be in the returned updated message object
+ expect(updatedMessage.expiredAt).toBeUndefined();
+
+ // Verify in database that expiredAt wasn't changed
+ const dbMessage = await Message.findOne({ messageId: 'msg123', user: 'user123' });
+ expect(dbMessage.expiredAt).toEqual(originalExpiredAt);
+ });
+
+ it('should preserve expiredAt when saving existing temporary message', async () => {
+ // First save a temporary message
+ mockReq.config.interfaceConfig.temporaryChatRetention = 24;
+
+ mockReq.body = { isTemporary: true };
+ const firstSave = await saveMessage(mockReq, mockMessageData);
+ const originalExpiredAt = firstSave.expiredAt;
+
+ // Wait a bit to ensure time difference
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ // Save again with same messageId but different text
+ const updatedData = { ...mockMessageData, text: 'Updated text' };
+ const secondSave = await saveMessage(mockReq, updatedData);
+
+ // Should update text but create new expiredAt
+ expect(secondSave.text).toBe('Updated text');
+ expect(secondSave.expiredAt).toBeDefined();
+ expect(new Date(secondSave.expiredAt).getTime()).toBeGreaterThan(
+ new Date(originalExpiredAt).getTime(),
+ );
+ });
+
+ it('should handle bulk operations with temporary messages', async () => {
+ // This test verifies bulkSaveMessages doesn't interfere with expiredAt
+ const messages = [
+ {
+ messageId: 'bulk1',
+ conversationId: uuidv4(),
+ text: 'Bulk message 1',
+ user: 'user123',
+ expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
+ },
+ {
+ messageId: 'bulk2',
+ conversationId: uuidv4(),
+ text: 'Bulk message 2',
+ user: 'user123',
+ expiredAt: null,
+ },
+ ];
+
+ await bulkSaveMessages(messages);
+
+ const savedMessages = await Message.find({
+ messageId: { $in: ['bulk1', 'bulk2'] },
+ }).lean();
+
+ expect(savedMessages).toHaveLength(2);
+
+ const bulk1 = savedMessages.find((m) => m.messageId === 'bulk1');
+ const bulk2 = savedMessages.find((m) => m.messageId === 'bulk2');
+
+ expect(bulk1.expiredAt).toBeDefined();
+ expect(bulk2.expiredAt).toBeNull();
+ });
+ });
+});
diff --git a/api/models/Preset.js b/api/models/Preset.js
new file mode 100644
index 0000000000..4db3d59066
--- /dev/null
+++ b/api/models/Preset.js
@@ -0,0 +1,82 @@
+const { logger } = require('@librechat/data-schemas');
+const { Preset } = require('~/db/models');
+
+const getPreset = async (user, presetId) => {
+ try {
+ return await Preset.findOne({ user, presetId }).lean();
+ } catch (error) {
+ logger.error('[getPreset] Error getting single preset', error);
+ return { message: 'Error getting single preset' };
+ }
+};
+
+module.exports = {
+ getPreset,
+ getPresets: async (user, filter) => {
+ try {
+ const presets = await Preset.find({ ...filter, user }).lean();
+ const defaultValue = 10000;
+
+ presets.sort((a, b) => {
+ let orderA = a.order !== undefined ? a.order : defaultValue;
+ let orderB = b.order !== undefined ? b.order : defaultValue;
+
+ if (orderA !== orderB) {
+ return orderA - orderB;
+ }
+
+ return b.updatedAt - a.updatedAt;
+ });
+
+ return presets;
+ } catch (error) {
+ logger.error('[getPresets] Error getting presets', error);
+ return { message: 'Error retrieving presets' };
+ }
+ },
+ savePreset: async (user, { presetId, newPresetId, defaultPreset, ...preset }) => {
+ try {
+ const setter = { $set: {} };
+ const { user: _, ...cleanPreset } = preset;
+ const update = { presetId, ...cleanPreset };
+ if (preset.tools && Array.isArray(preset.tools)) {
+ update.tools =
+ preset.tools
+ .map((tool) => tool?.pluginKey ?? tool)
+ .filter((toolName) => typeof toolName === 'string') ?? [];
+ }
+ if (newPresetId) {
+ update.presetId = newPresetId;
+ }
+
+ if (defaultPreset) {
+ update.defaultPreset = defaultPreset;
+ update.order = 0;
+
+ const currentDefault = await Preset.findOne({ defaultPreset: true, user });
+
+ if (currentDefault && currentDefault.presetId !== presetId) {
+ await Preset.findByIdAndUpdate(currentDefault._id, {
+ $unset: { defaultPreset: '', order: '' },
+ });
+ }
+ } else if (defaultPreset === false) {
+ update.defaultPreset = undefined;
+ update.order = undefined;
+ setter['$unset'] = { defaultPreset: '', order: '' };
+ }
+
+ setter.$set = update;
+ return await Preset.findOneAndUpdate({ presetId, user }, setter, { new: true, upsert: true });
+ } catch (error) {
+ logger.error('[savePreset] Error saving preset', error);
+ return { message: 'Error saving preset' };
+ }
+ },
+ deletePresets: async (user, filter) => {
+ // let toRemove = await Preset.find({ ...filter, user }).select('presetId');
+ // const ids = toRemove.map((instance) => instance.presetId);
+ let deleteCount = await Preset.deleteMany({ ...filter, user });
+ return deleteCount;
+ },
+};
diff --git a/api/models/Project.js b/api/models/Project.js
new file mode 100644
index 0000000000..8fd1e556f9
--- /dev/null
+++ b/api/models/Project.js
@@ -0,0 +1,133 @@
+const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
+const { Project } = require('~/db/models');
+
+/**
+ * Retrieve a project by ID and convert the found project document to a plain object.
+ *
+ * @param {string} projectId - The ID of the project to find and return as a plain object.
+ * @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
+ * @returns {Promise} A plain object representing the project document, or `null` if no project is found.
+ */
+const getProjectById = async function (projectId, fieldsToSelect = null) {
+ const query = Project.findById(projectId);
+
+ if (fieldsToSelect) {
+ query.select(fieldsToSelect);
+ }
+
+ return await query.lean();
+};
+
+/**
+ * Retrieve a project by name and convert the found project document to a plain object.
+ * If the project with the given name doesn't exist and the name is "instance", create it and return the lean version.
+ *
+ * @param {string} projectName - The name of the project to find or create.
+ * @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
+ * @returns {Promise} A plain object representing the project document.
+ */
+const getProjectByName = async function (projectName, fieldsToSelect = null) {
+ const query = { name: projectName };
+ const update = { $setOnInsert: { name: projectName } };
+ const options = {
+ new: true,
+ upsert: projectName === GLOBAL_PROJECT_NAME,
+ lean: true,
+ select: fieldsToSelect,
+ };
+
+ return await Project.findOneAndUpdate(query, update, options);
+};
+
+/**
+ * Add an array of prompt group IDs to a project's promptGroupIds array, ensuring uniqueness.
+ *
+ * @param {string} projectId - The ID of the project to update.
+ * @param {string[]} promptGroupIds - The array of prompt group IDs to add to the project.
+ * @returns {Promise} The updated project document.
+ */
+const addGroupIdsToProject = async function (projectId, promptGroupIds) {
+ return await Project.findByIdAndUpdate(
+ projectId,
+ { $addToSet: { promptGroupIds: { $each: promptGroupIds } } },
+ { new: true },
+ );
+};
+
+/**
+ * Remove an array of prompt group IDs from a project's promptGroupIds array.
+ *
+ * @param {string} projectId - The ID of the project to update.
+ * @param {string[]} promptGroupIds - The array of prompt group IDs to remove from the project.
+ * @returns {Promise} The updated project document.
+ */
+const removeGroupIdsFromProject = async function (projectId, promptGroupIds) {
+ return await Project.findByIdAndUpdate(
+ projectId,
+ { $pull: { promptGroupIds: { $in: promptGroupIds } } },
+ { new: true },
+ );
+};
+
+/**
+ * Remove a prompt group ID from all projects.
+ *
+ * @param {string} promptGroupId - The ID of the prompt group to remove from projects.
+ * @returns {Promise}
+ */
+const removeGroupFromAllProjects = async (promptGroupId) => {
+ await Project.updateMany({}, { $pull: { promptGroupIds: promptGroupId } });
+};
+
+/**
+ * Add an array of agent IDs to a project's agentIds array, ensuring uniqueness.
+ *
+ * @param {string} projectId - The ID of the project to update.
+ * @param {string[]} agentIds - The array of agent IDs to add to the project.
+ * @returns {Promise} The updated project document.
+ */
+const addAgentIdsToProject = async function (projectId, agentIds) {
+ return await Project.findByIdAndUpdate(
+ projectId,
+ { $addToSet: { agentIds: { $each: agentIds } } },
+ { new: true },
+ );
+};
+
+/**
+ * Remove an array of agent IDs from a project's agentIds array.
+ *
+ * @param {string} projectId - The ID of the project to update.
+ * @param {string[]} agentIds - The array of agent IDs to remove from the project.
+ * @returns {Promise} The updated project document.
+ */
+const removeAgentIdsFromProject = async function (projectId, agentIds) {
+ return await Project.findByIdAndUpdate(
+ projectId,
+ { $pull: { agentIds: { $in: agentIds } } },
+ { new: true },
+ );
+};
+
+/**
+ * Remove an agent ID from all projects.
+ *
+ * @param {string} agentId - The ID of the agent to remove from projects.
+ * @returns {Promise}
+ */
+const removeAgentFromAllProjects = async (agentId) => {
+ await Project.updateMany({}, { $pull: { agentIds: agentId } });
+};
+
+module.exports = {
+ getProjectById,
+ getProjectByName,
+ /* prompts */
+ addGroupIdsToProject,
+ removeGroupIdsFromProject,
+ removeGroupFromAllProjects,
+ /* agents */
+ addAgentIdsToProject,
+ removeAgentIdsFromProject,
+ removeAgentFromAllProjects,
+};
diff --git a/api/models/Prompt.js b/api/models/Prompt.js
index 612278d038..bde911b23a 100644
--- a/api/models/Prompt.js
+++ b/api/models/Prompt.js
@@ -1,52 +1,708 @@
-const mongoose = require('mongoose');
+const { ObjectId } = require('mongodb');
+const { escapeRegExp } = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+const {
+ Constants,
+ SystemRoles,
+ ResourceType,
+ SystemCategories,
+} = require('librechat-data-provider');
+const {
+ removeGroupFromAllProjects,
+ removeGroupIdsFromProject,
+ addGroupIdsToProject,
+ getProjectByName,
+} = require('./Project');
+const { removeAllPermissions } = require('~/server/services/PermissionService');
+const { PromptGroup, Prompt, AclEntry } = require('~/db/models');
-const promptSchema = mongoose.Schema({
- title: {
- type: String,
- required: true
+/**
+ * Create a pipeline for the aggregation to get prompt groups
+ * @param {Object} query
+ * @param {number} skip
+ * @param {number} limit
+ * @returns {[Object]} - The pipeline for the aggregation
+ */
+const createGroupPipeline = (query, skip, limit) => {
+ return [
+ { $match: query },
+ { $sort: { createdAt: -1 } },
+ { $skip: skip },
+ { $limit: limit },
+ {
+ $lookup: {
+ from: 'prompts',
+ localField: 'productionId',
+ foreignField: '_id',
+ as: 'productionPrompt',
+ },
+ },
+ { $unwind: { path: '$productionPrompt', preserveNullAndEmptyArrays: true } },
+ {
+ $project: {
+ name: 1,
+ numberOfGenerations: 1,
+ oneliner: 1,
+ category: 1,
+ projectIds: 1,
+ productionId: 1,
+ author: 1,
+ authorName: 1,
+ createdAt: 1,
+ updatedAt: 1,
+ 'productionPrompt.prompt': 1,
+ // 'productionPrompt._id': 1,
+ // 'productionPrompt.type': 1,
+ },
+ },
+ ];
+};
+
+/**
+ * Create a pipeline for the aggregation to get all prompt groups
+ * @param {Object} query
+ * @param {Partial} $project
+ * @returns {[Object]} - The pipeline for the aggregation
+ */
+const createAllGroupsPipeline = (
+ query,
+ $project = {
+ name: 1,
+ oneliner: 1,
+ category: 1,
+ author: 1,
+ authorName: 1,
+ createdAt: 1,
+ updatedAt: 1,
+ command: 1,
+ 'productionPrompt.prompt': 1,
},
- prompt: {
- type: String,
- required: true
- },
- category: {
- type: String,
- },
- created: {
- type: Date,
- default: Date.now
+) => {
+ return [
+ { $match: query },
+ { $sort: { createdAt: -1 } },
+ {
+ $lookup: {
+ from: 'prompts',
+ localField: 'productionId',
+ foreignField: '_id',
+ as: 'productionPrompt',
+ },
+ },
+ { $unwind: { path: '$productionPrompt', preserveNullAndEmptyArrays: true } },
+ {
+ $project,
+ },
+ ];
+};
+
+/**
+ * Get all prompt groups with filters
+ * @param {ServerRequest} req
+ * @param {TPromptGroupsWithFilterRequest} filter
+ * @returns {Promise}
+ */
+const getAllPromptGroups = async (req, filter) => {
+ try {
+ const { name, ...query } = filter;
+
+ let searchShared = true;
+ let searchSharedOnly = false;
+ if (name) {
+ query.name = new RegExp(escapeRegExp(name), 'i');
+ }
+ if (!query.category) {
+ delete query.category;
+ } else if (query.category === SystemCategories.MY_PROMPTS) {
+ searchShared = false;
+ delete query.category;
+ } else if (query.category === SystemCategories.NO_CATEGORY) {
+ query.category = '';
+ } else if (query.category === SystemCategories.SHARED_PROMPTS) {
+ searchSharedOnly = true;
+ delete query.category;
+ }
+
+ let combinedQuery = query;
+
+ if (searchShared) {
+ const project = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, 'promptGroupIds');
+ if (project && project.promptGroupIds && project.promptGroupIds.length > 0) {
+ const projectQuery = { _id: { $in: project.promptGroupIds }, ...query };
+ delete projectQuery.author;
+ combinedQuery = searchSharedOnly ? projectQuery : { $or: [projectQuery, query] };
+ }
+ }
+
+ const promptGroupsPipeline = createAllGroupsPipeline(combinedQuery);
+ return await PromptGroup.aggregate(promptGroupsPipeline).exec();
+ } catch (error) {
+ console.error('Error getting all prompt groups', error);
+ return { message: 'Error getting all prompt groups' };
}
-});
+};
-const Prompt = mongoose.models.Prompt || mongoose.model('Prompt', promptSchema);
+/**
+ * Get prompt groups with filters
+ * @param {ServerRequest} req
+ * @param {TPromptGroupsWithFilterRequest} filter
+ * @returns {Promise}
+ */
+const getPromptGroups = async (req, filter) => {
+ try {
+ const { pageNumber = 1, pageSize = 10, name, ...query } = filter;
+
+ const validatedPageNumber = Math.max(parseInt(pageNumber, 10), 1);
+ const validatedPageSize = Math.max(parseInt(pageSize, 10), 1);
+
+ let searchShared = true;
+ let searchSharedOnly = false;
+ if (name) {
+ query.name = new RegExp(escapeRegExp(name), 'i');
+ }
+ if (!query.category) {
+ delete query.category;
+ } else if (query.category === SystemCategories.MY_PROMPTS) {
+ searchShared = false;
+ delete query.category;
+ } else if (query.category === SystemCategories.NO_CATEGORY) {
+ query.category = '';
+ } else if (query.category === SystemCategories.SHARED_PROMPTS) {
+ searchSharedOnly = true;
+ delete query.category;
+ }
+
+ let combinedQuery = query;
+
+ if (searchShared) {
+ // const projects = req.user.projects || []; // TODO: handle multiple projects
+ const project = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, 'promptGroupIds');
+ if (project && project.promptGroupIds && project.promptGroupIds.length > 0) {
+ const projectQuery = { _id: { $in: project.promptGroupIds }, ...query };
+ delete projectQuery.author;
+ combinedQuery = searchSharedOnly ? projectQuery : { $or: [projectQuery, query] };
+ }
+ }
+
+ const skip = (validatedPageNumber - 1) * validatedPageSize;
+ const limit = validatedPageSize;
+
+ const promptGroupsPipeline = createGroupPipeline(combinedQuery, skip, limit);
+ const totalPromptGroupsPipeline = [{ $match: combinedQuery }, { $count: 'total' }];
+
+ const [promptGroupsResults, totalPromptGroupsResults] = await Promise.all([
+ PromptGroup.aggregate(promptGroupsPipeline).exec(),
+ PromptGroup.aggregate(totalPromptGroupsPipeline).exec(),
+ ]);
+
+ const promptGroups = promptGroupsResults;
+ const totalPromptGroups =
+ totalPromptGroupsResults.length > 0 ? totalPromptGroupsResults[0].total : 0;
+
+ return {
+ promptGroups,
+ pageNumber: validatedPageNumber.toString(),
+ pageSize: validatedPageSize.toString(),
+ pages: Math.ceil(totalPromptGroups / validatedPageSize).toString(),
+ };
+ } catch (error) {
+ console.error('Error getting prompt groups', error);
+ return { message: 'Error getting prompt groups' };
+ }
+};
+
+/**
+ * @param {Object} fields
+ * @param {string} fields._id
+ * @param {string} fields.author
+ * @param {string} fields.role
+ * @returns {Promise}
+ */
+const deletePromptGroup = async ({ _id, author, role }) => {
+ // Build query - with ACL, author is optional
+ const query = { _id };
+ const groupQuery = { groupId: new ObjectId(_id) };
+
+ // Legacy: Add author filter if provided (backward compatibility)
+ if (author && role !== SystemRoles.ADMIN) {
+ query.author = author;
+ groupQuery.author = author;
+ }
+
+ const response = await PromptGroup.deleteOne(query);
+
+ if (!response || response.deletedCount === 0) {
+ throw new Error('Prompt group not found');
+ }
+
+ await Prompt.deleteMany(groupQuery);
+ await removeGroupFromAllProjects(_id);
+
+ try {
+ await removeAllPermissions({ resourceType: ResourceType.PROMPTGROUP, resourceId: _id });
+ } catch (error) {
+ logger.error('Error removing promptGroup permissions:', error);
+ }
+
+ return { message: 'Prompt group deleted successfully' };
+};
+
+/**
+ * Get prompt groups by accessible IDs with optional cursor-based pagination.
+ * @param {Object} params - The parameters for getting accessible prompt groups.
+ * @param {Array} [params.accessibleIds] - Array of prompt group ObjectIds the user has ACL access to.
+ * @param {Object} [params.otherParams] - Additional query parameters (including author filter).
+ * @param {number} [params.limit] - Number of prompt groups to return (max 100). If not provided, returns all prompt groups.
+ * @param {string} [params.after] - Cursor for pagination - get prompt groups after this cursor. // base64 encoded JSON string with updatedAt and _id.
+ * @returns {Promise} A promise that resolves to an object containing the prompt groups data and pagination info.
+ */
+async function getListPromptGroupsByAccess({
+ accessibleIds = [],
+ otherParams = {},
+ limit = null,
+ after = null,
+}) {
+ const isPaginated = limit !== null && limit !== undefined;
+ const normalizedLimit = isPaginated ? Math.min(Math.max(1, parseInt(limit) || 20), 100) : null;
+
+ // Build base query combining ACL accessible prompt groups with other filters
+ const baseQuery = { ...otherParams, _id: { $in: accessibleIds } };
+
+ // Add cursor condition
+ if (after && typeof after === 'string' && after !== 'undefined' && after !== 'null') {
+ try {
+ const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8'));
+ const { updatedAt, _id } = cursor;
+
+ const cursorCondition = {
+ $or: [
+ { updatedAt: { $lt: new Date(updatedAt) } },
+ { updatedAt: new Date(updatedAt), _id: { $gt: new ObjectId(_id) } },
+ ],
+ };
+
+ // Merge cursor condition with base query
+ if (Object.keys(baseQuery).length > 0) {
+ baseQuery.$and = [{ ...baseQuery }, cursorCondition];
+ // Remove the original conditions from baseQuery to avoid duplication
+ Object.keys(baseQuery).forEach((key) => {
+ if (key !== '$and') delete baseQuery[key];
+ });
+ } else {
+ Object.assign(baseQuery, cursorCondition);
+ }
+ } catch (error) {
+ logger.warn('Invalid cursor:', error.message);
+ }
+ }
+
+ // Build aggregation pipeline
+ const pipeline = [{ $match: baseQuery }, { $sort: { updatedAt: -1, _id: 1 } }];
+
+ // Only apply limit if pagination is requested
+ if (isPaginated) {
+ pipeline.push({ $limit: normalizedLimit + 1 });
+ }
+
+ // Add lookup for production prompt
+ pipeline.push(
+ {
+ $lookup: {
+ from: 'prompts',
+ localField: 'productionId',
+ foreignField: '_id',
+ as: 'productionPrompt',
+ },
+ },
+ { $unwind: { path: '$productionPrompt', preserveNullAndEmptyArrays: true } },
+ {
+ $project: {
+ name: 1,
+ numberOfGenerations: 1,
+ oneliner: 1,
+ category: 1,
+ projectIds: 1,
+ productionId: 1,
+ author: 1,
+ authorName: 1,
+ createdAt: 1,
+ updatedAt: 1,
+ 'productionPrompt.prompt': 1,
+ },
+ },
+ );
+
+ const promptGroups = await PromptGroup.aggregate(pipeline).exec();
+
+ const hasMore = isPaginated ? promptGroups.length > normalizedLimit : false;
+ const data = (isPaginated ? promptGroups.slice(0, normalizedLimit) : promptGroups).map(
+ (group) => {
+ if (group.author) {
+ group.author = group.author.toString();
+ }
+ return group;
+ },
+ );
+
+ // Generate next cursor only if paginated
+ let nextCursor = null;
+ if (isPaginated && hasMore && data.length > 0) {
+ const lastGroup = promptGroups[normalizedLimit - 1];
+ nextCursor = Buffer.from(
+ JSON.stringify({
+ updatedAt: lastGroup.updatedAt.toISOString(),
+ _id: lastGroup._id.toString(),
+ }),
+ ).toString('base64');
+ }
+
+ return {
+ object: 'list',
+ data,
+ first_id: data.length > 0 ? data[0]._id.toString() : null,
+ last_id: data.length > 0 ? data[data.length - 1]._id.toString() : null,
+ has_more: hasMore,
+ after: nextCursor,
+ };
+}
module.exports = {
- savePrompt: async ({ title, prompt }) => {
+ getPromptGroups,
+ deletePromptGroup,
+ getAllPromptGroups,
+ getListPromptGroupsByAccess,
+ /**
+ * Create a prompt and its respective group
+ * @param {TCreatePromptRecord} saveData
+ * @returns {Promise}
+ */
+ createPromptGroup: async (saveData) => {
try {
- await Prompt.create({
- title,
- prompt
- });
- return { title, prompt };
+ const { prompt, group, author, authorName } = saveData;
+
+ let newPromptGroup = await PromptGroup.findOneAndUpdate(
+ { ...group, author, authorName, productionId: null },
+ { $setOnInsert: { ...group, author, authorName, productionId: null } },
+ { new: true, upsert: true },
+ )
+ .lean()
+ .select('-__v')
+ .exec();
+
+ const newPrompt = await Prompt.findOneAndUpdate(
+ { ...prompt, author, groupId: newPromptGroup._id },
+ { $setOnInsert: { ...prompt, author, groupId: newPromptGroup._id } },
+ { new: true, upsert: true },
+ )
+ .lean()
+ .select('-__v')
+ .exec();
+
+ newPromptGroup = await PromptGroup.findByIdAndUpdate(
+ newPromptGroup._id,
+ { productionId: newPrompt._id },
+ { new: true },
+ )
+ .lean()
+ .select('-__v')
+ .exec();
+
+ return {
+ prompt: newPrompt,
+ group: {
+ ...newPromptGroup,
+ productionPrompt: { prompt: newPrompt.prompt },
+ },
+ };
} catch (error) {
- console.error(error);
- return { prompt: 'Error saving prompt' };
+ logger.error('Error saving prompt group', error);
+ throw new Error('Error saving prompt group');
+ }
+ },
+ /**
+ * Save a prompt
+ * @param {TCreatePromptRecord} saveData
+ * @returns {Promise}
+ */
+ savePrompt: async (saveData) => {
+ try {
+ const { prompt, author } = saveData;
+ const newPromptData = {
+ ...prompt,
+ author,
+ };
+
+ /** @type {TPrompt} */
+ let newPrompt;
+ try {
+ newPrompt = await Prompt.create(newPromptData);
+ } catch (error) {
+ if (error?.message?.includes('groupId_1_version_1')) {
+ await Prompt.db.collection('prompts').dropIndex('groupId_1_version_1');
+ } else {
+ throw error;
+ }
+ newPrompt = await Prompt.create(newPromptData);
+ }
+
+ return { prompt: newPrompt };
+ } catch (error) {
+ logger.error('Error saving prompt', error);
+ return { message: 'Error saving prompt' };
}
},
getPrompts: async (filter) => {
try {
- return await Prompt.find(filter).exec()
+ return await Prompt.find(filter).sort({ createdAt: -1 }).lean();
} catch (error) {
- console.error(error);
- return { prompt: 'Error getting prompts' };
+ logger.error('Error getting prompts', error);
+ return { message: 'Error getting prompts' };
}
},
- deletePrompts: async (filter) => {
+ getPrompt: async (filter) => {
try {
- return await Prompt.deleteMany(filter).exec()
+ if (filter.groupId) {
+ filter.groupId = new ObjectId(filter.groupId);
+ }
+ return await Prompt.findOne(filter).lean();
} catch (error) {
- console.error(error);
- return { prompt: 'Error deleting prompts' };
+ logger.error('Error getting prompt', error);
+ return { message: 'Error getting prompt' };
}
- }
-}
\ No newline at end of file
+ },
+ /**
+ * Get prompt groups with filters
+ * @param {TGetRandomPromptsRequest} filter
+ * @returns {Promise}
+ */
+ getRandomPromptGroups: async (filter) => {
+ try {
+ const result = await PromptGroup.aggregate([
+ {
+ $match: {
+ category: { $ne: '' },
+ },
+ },
+ {
+ $group: {
+ _id: '$category',
+ promptGroup: { $first: '$$ROOT' },
+ },
+ },
+ {
+ $replaceRoot: { newRoot: '$promptGroup' },
+ },
+ {
+ $sample: { size: +filter.limit + +filter.skip },
+ },
+ {
+ $skip: +filter.skip,
+ },
+ {
+ $limit: +filter.limit,
+ },
+ ]);
+ return { prompts: result };
+ } catch (error) {
+ logger.error('Error getting prompt groups', error);
+ return { message: 'Error getting prompt groups' };
+ }
+ },
+ getPromptGroupsWithPrompts: async (filter) => {
+ try {
+ return await PromptGroup.findOne(filter)
+ .populate({
+ path: 'prompts',
+ select: '-_id -__v -user',
+ })
+ .select('-_id -__v -user')
+ .lean();
+ } catch (error) {
+ logger.error('Error getting prompt groups', error);
+ return { message: 'Error getting prompt groups' };
+ }
+ },
+ getPromptGroup: async (filter) => {
+ try {
+ return await PromptGroup.findOne(filter).lean();
+ } catch (error) {
+ logger.error('Error getting prompt group', error);
+ return { message: 'Error getting prompt group' };
+ }
+ },
+ /**
+ * Deletes a prompt and its corresponding prompt group if it is the last prompt in the group.
+ *
+ * @param {Object} options - The options for deleting the prompt.
+ * @param {ObjectId|string} options.promptId - The ID of the prompt to delete.
+ * @param {ObjectId|string} options.groupId - The ID of the prompt's group.
+ * @param {ObjectId|string} options.author - The ID of the prompt's author.
+ * @param {string} options.role - The role of the prompt's author.
+ * @return {Promise} An object containing the result of the deletion.
+ * If the prompt was deleted successfully, the object will have a property 'prompt' with the value 'Prompt deleted successfully'.
+ * If the prompt group was deleted successfully, the object will have a property 'promptGroup' with the message 'Prompt group deleted successfully' and id of the deleted group.
+ * If there was an error deleting the prompt, the object will have a property 'message' with the value 'Error deleting prompt'.
+ */
+ deletePrompt: async ({ promptId, groupId, author, role }) => {
+ const query = { _id: promptId, groupId, author };
+ if (role === SystemRoles.ADMIN) {
+ delete query.author;
+ }
+ const { deletedCount } = await Prompt.deleteOne(query);
+ if (deletedCount === 0) {
+ throw new Error('Failed to delete the prompt');
+ }
+
+ const remainingPrompts = await Prompt.find({ groupId })
+ .select('_id')
+ .sort({ createdAt: 1 })
+ .lean();
+
+ if (remainingPrompts.length === 0) {
+ // Remove all ACL entries for the promptGroup when deleting the last prompt
+ try {
+ await removeAllPermissions({
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: groupId,
+ });
+ } catch (error) {
+ logger.error('Error removing promptGroup permissions:', error);
+ }
+
+ await PromptGroup.deleteOne({ _id: groupId });
+ await removeGroupFromAllProjects(groupId);
+
+ return {
+ prompt: 'Prompt deleted successfully',
+ promptGroup: {
+ message: 'Prompt group deleted successfully',
+ id: groupId,
+ },
+ };
+ } else {
+ const promptGroup = await PromptGroup.findById(groupId).lean();
+ if (promptGroup.productionId.toString() === promptId.toString()) {
+ await PromptGroup.updateOne(
+ { _id: groupId },
+ { productionId: remainingPrompts[remainingPrompts.length - 1]._id },
+ );
+ }
+
+ return { prompt: 'Prompt deleted successfully' };
+ }
+ },
+ /**
+ * Delete all prompts and prompt groups created by a specific user.
+ * @param {ServerRequest} req - The server request object.
+ * @param {string} userId - The ID of the user whose prompts and prompt groups are to be deleted.
+ */
+ deleteUserPrompts: async (req, userId) => {
+ try {
+ const promptGroups = await getAllPromptGroups(req, { author: new ObjectId(userId) });
+
+ if (promptGroups.length === 0) {
+ return;
+ }
+
+ const groupIds = promptGroups.map((group) => group._id);
+
+ for (const groupId of groupIds) {
+ await removeGroupFromAllProjects(groupId);
+ }
+
+ await AclEntry.deleteMany({
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: { $in: groupIds },
+ });
+
+ await PromptGroup.deleteMany({ author: new ObjectId(userId) });
+ await Prompt.deleteMany({ author: new ObjectId(userId) });
+ } catch (error) {
+ logger.error('[deleteUserPrompts] General error:', error);
+ }
+ },
+ /**
+ * Update prompt group
+ * @param {Partial} filter - Filter to find prompt group
+ * @param {Partial} data - Data to update
+ * @returns {Promise}
+ */
+ updatePromptGroup: async (filter, data) => {
+ try {
+ const updateOps = {};
+ if (data.removeProjectIds) {
+ for (const projectId of data.removeProjectIds) {
+ await removeGroupIdsFromProject(projectId, [filter._id]);
+ }
+
+ updateOps.$pull = { projectIds: { $in: data.removeProjectIds } };
+ delete data.removeProjectIds;
+ }
+
+ if (data.projectIds) {
+ for (const projectId of data.projectIds) {
+ await addGroupIdsToProject(projectId, [filter._id]);
+ }
+
+ updateOps.$addToSet = { projectIds: { $each: data.projectIds } };
+ delete data.projectIds;
+ }
+
+ const updateData = { ...data, ...updateOps };
+ const updatedDoc = await PromptGroup.findOneAndUpdate(filter, updateData, {
+ new: true,
+ upsert: false,
+ });
+
+ if (!updatedDoc) {
+ throw new Error('Prompt group not found');
+ }
+
+ return updatedDoc;
+ } catch (error) {
+ logger.error('Error updating prompt group', error);
+ return { message: 'Error updating prompt group' };
+ }
+ },
+ /**
+ * Function to make a prompt production based on its ID.
+ * @param {String} promptId - The ID of the prompt to make production.
+ * @returns {Object} The result of the production operation.
+ */
+ makePromptProduction: async (promptId) => {
+ try {
+ const prompt = await Prompt.findById(promptId).lean();
+
+ if (!prompt) {
+ throw new Error('Prompt not found');
+ }
+
+ await PromptGroup.findByIdAndUpdate(
+ prompt.groupId,
+ { productionId: prompt._id },
+ { new: true },
+ )
+ .lean()
+ .exec();
+
+ return {
+ message: 'Prompt production made successfully',
+ };
+ } catch (error) {
+ logger.error('Error making prompt production', error);
+ return { message: 'Error making prompt production' };
+ }
+ },
+ updatePromptLabels: async (_id, labels) => {
+ try {
+ const response = await Prompt.updateOne({ _id }, { $set: { labels } });
+ if (response.matchedCount === 0) {
+ return { message: 'Prompt not found' };
+ }
+ return { message: 'Prompt labels updated successfully' };
+ } catch (error) {
+ logger.error('Error updating prompt labels', error);
+ return { message: 'Error updating prompt labels' };
+ }
+ },
+};
diff --git a/api/models/Prompt.spec.js b/api/models/Prompt.spec.js
new file mode 100644
index 0000000000..e00a1a518c
--- /dev/null
+++ b/api/models/Prompt.spec.js
@@ -0,0 +1,564 @@
+const mongoose = require('mongoose');
+const { ObjectId } = require('mongodb');
+const { logger } = require('@librechat/data-schemas');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const {
+ SystemRoles,
+ ResourceType,
+ AccessRoleIds,
+ PrincipalType,
+ PermissionBits,
+} = require('librechat-data-provider');
+
+// Mock the config/connect module to prevent connection attempts during tests
+jest.mock('../../config/connect', () => jest.fn().mockResolvedValue(true));
+
+const dbModels = require('~/db/models');
+
+// Disable console for tests
+logger.silent = true;
+
+let mongoServer;
+let Prompt, PromptGroup, AclEntry, AccessRole, User, Group, Project;
+let promptFns, permissionService;
+let testUsers, testGroups, testRoles;
+
+beforeAll(async () => {
+ // Set up MongoDB memory server
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+
+ // Initialize models
+ Prompt = dbModels.Prompt;
+ PromptGroup = dbModels.PromptGroup;
+ AclEntry = dbModels.AclEntry;
+ AccessRole = dbModels.AccessRole;
+ User = dbModels.User;
+ Group = dbModels.Group;
+ Project = dbModels.Project;
+
+ promptFns = require('~/models/Prompt');
+ permissionService = require('~/server/services/PermissionService');
+
+ // Create test data
+ await setupTestData();
+});
+
+afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ jest.clearAllMocks();
+});
+
+async function setupTestData() {
+ // Create access roles for promptGroups
+ testRoles = {
+ viewer: await AccessRole.create({
+ accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
+ name: 'Viewer',
+ description: 'Can view promptGroups',
+ resourceType: ResourceType.PROMPTGROUP,
+ permBits: PermissionBits.VIEW,
+ }),
+ editor: await AccessRole.create({
+ accessRoleId: AccessRoleIds.PROMPTGROUP_EDITOR,
+ name: 'Editor',
+ description: 'Can view and edit promptGroups',
+ resourceType: ResourceType.PROMPTGROUP,
+ permBits: PermissionBits.VIEW | PermissionBits.EDIT,
+ }),
+ owner: await AccessRole.create({
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ name: 'Owner',
+ description: 'Full control over promptGroups',
+ resourceType: ResourceType.PROMPTGROUP,
+ permBits:
+ PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
+ }),
+ };
+
+ // Create test users
+ testUsers = {
+ owner: await User.create({
+ name: 'Prompt Owner',
+ email: 'owner@example.com',
+ role: SystemRoles.USER,
+ }),
+ editor: await User.create({
+ name: 'Prompt Editor',
+ email: 'editor@example.com',
+ role: SystemRoles.USER,
+ }),
+ viewer: await User.create({
+ name: 'Prompt Viewer',
+ email: 'viewer@example.com',
+ role: SystemRoles.USER,
+ }),
+ admin: await User.create({
+ name: 'Admin User',
+ email: 'admin@example.com',
+ role: SystemRoles.ADMIN,
+ }),
+ noAccess: await User.create({
+ name: 'No Access User',
+ email: 'noaccess@example.com',
+ role: SystemRoles.USER,
+ }),
+ };
+
+ // Create test groups
+ testGroups = {
+ editors: await Group.create({
+ name: 'Prompt Editors',
+ description: 'Group with editor access',
+ }),
+ viewers: await Group.create({
+ name: 'Prompt Viewers',
+ description: 'Group with viewer access',
+ }),
+ };
+
+ await Project.create({
+ name: 'Global',
+ description: 'Global project',
+ promptGroupIds: [],
+ });
+}
+
+describe('Prompt ACL Permissions', () => {
+ describe('Creating Prompts with Permissions', () => {
+ it('should grant owner permissions when creating a prompt', async () => {
+ // First create a group
+ const testGroup = await PromptGroup.create({
+ name: 'Test Group',
+ category: 'testing',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new mongoose.Types.ObjectId(),
+ });
+
+ const promptData = {
+ prompt: {
+ prompt: 'Test prompt content',
+ name: 'Test Prompt',
+ type: 'text',
+ groupId: testGroup._id,
+ },
+ author: testUsers.owner._id,
+ };
+
+ await promptFns.savePrompt(promptData);
+
+ // Manually grant permissions as would happen in the route
+ await permissionService.grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testGroup._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+
+ // Check ACL entry
+ const aclEntry = await AclEntry.findOne({
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testGroup._id,
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ });
+
+ expect(aclEntry).toBeTruthy();
+ expect(aclEntry.permBits).toBe(testRoles.owner.permBits);
+ });
+ });
+
+ describe('Accessing Prompts', () => {
+ let testPromptGroup;
+
+ beforeEach(async () => {
+ // Create a prompt group
+ testPromptGroup = await PromptGroup.create({
+ name: 'Test Group',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ });
+
+ // Create a prompt
+ await Prompt.create({
+ prompt: 'Test prompt for access control',
+ name: 'Access Test Prompt',
+ author: testUsers.owner._id,
+ groupId: testPromptGroup._id,
+ type: 'text',
+ });
+
+ // Grant owner permissions
+ await permissionService.grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testPromptGroup._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+ });
+
+ afterEach(async () => {
+ await Prompt.deleteMany({});
+ await PromptGroup.deleteMany({});
+ await AclEntry.deleteMany({});
+ });
+
+ it('owner should have full access to their prompt', async () => {
+ const hasAccess = await permissionService.checkPermission({
+ userId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testPromptGroup._id,
+ requiredPermission: PermissionBits.VIEW,
+ });
+
+ expect(hasAccess).toBe(true);
+
+ const canEdit = await permissionService.checkPermission({
+ userId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testPromptGroup._id,
+ requiredPermission: PermissionBits.EDIT,
+ });
+
+ expect(canEdit).toBe(true);
+ });
+
+ it('user with viewer role should only have view access', async () => {
+ // Grant viewer permissions
+ await permissionService.grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.viewer._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testPromptGroup._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
+ grantedBy: testUsers.owner._id,
+ });
+
+ const canView = await permissionService.checkPermission({
+ userId: testUsers.viewer._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testPromptGroup._id,
+ requiredPermission: PermissionBits.VIEW,
+ });
+
+ const canEdit = await permissionService.checkPermission({
+ userId: testUsers.viewer._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testPromptGroup._id,
+ requiredPermission: PermissionBits.EDIT,
+ });
+
+ expect(canView).toBe(true);
+ expect(canEdit).toBe(false);
+ });
+
+ it('user without permissions should have no access', async () => {
+ const hasAccess = await permissionService.checkPermission({
+ userId: testUsers.noAccess._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testPromptGroup._id,
+ requiredPermission: PermissionBits.VIEW,
+ });
+
+ expect(hasAccess).toBe(false);
+ });
+
+ it('admin should have access regardless of permissions', async () => {
+ // Admin users should work through normal permission system
+ // The middleware layer handles admin bypass, not the permission service
+ const hasAccess = await permissionService.checkPermission({
+ userId: testUsers.admin._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testPromptGroup._id,
+ requiredPermission: PermissionBits.VIEW,
+ });
+
+ // Without explicit permissions, even admin won't have access at this layer
+ expect(hasAccess).toBe(false);
+
+ // The actual admin bypass happens in the middleware layer (`canAccessPromptViaGroup`/`canAccessPromptGroupResource`)
+ // which checks req.user.role === SystemRoles.ADMIN
+ });
+ });
+
+ describe('Group-based Access', () => {
+ let testPromptGroup;
+
+ beforeEach(async () => {
+ // Create a prompt group first
+ testPromptGroup = await PromptGroup.create({
+ name: 'Group Access Test Group',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ });
+
+ await Prompt.create({
+ prompt: 'Group access test prompt',
+ name: 'Group Test',
+ author: testUsers.owner._id,
+ groupId: testPromptGroup._id,
+ type: 'text',
+ });
+
+ // Add users to groups
+ await User.findByIdAndUpdate(testUsers.editor._id, {
+ $push: { groups: testGroups.editors._id },
+ });
+
+ await User.findByIdAndUpdate(testUsers.viewer._id, {
+ $push: { groups: testGroups.viewers._id },
+ });
+ });
+
+ afterEach(async () => {
+ await Prompt.deleteMany({});
+ await AclEntry.deleteMany({});
+ await User.updateMany({}, { $set: { groups: [] } });
+ });
+
+ it('group members should inherit group permissions', async () => {
+ // Create a prompt group
+ const testPromptGroup = await PromptGroup.create({
+ name: 'Group Test Group',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ });
+
+ const { addUserToGroup } = require('~/models');
+ await addUserToGroup(testUsers.editor._id, testGroups.editors._id);
+
+ const prompt = await promptFns.savePrompt({
+ author: testUsers.owner._id,
+ prompt: {
+ prompt: 'Group test prompt',
+ name: 'Group Test',
+ groupId: testPromptGroup._id,
+ type: 'text',
+ },
+ });
+
+ // Check if savePrompt returned an error
+ if (!prompt || !prompt.prompt) {
+ throw new Error(`Failed to save prompt: ${prompt?.message || 'Unknown error'}`);
+ }
+
+ // Grant edit permissions to the group
+ await permissionService.grantPermission({
+ principalType: PrincipalType.GROUP,
+ principalId: testGroups.editors._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testPromptGroup._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_EDITOR,
+ grantedBy: testUsers.owner._id,
+ });
+
+ // Check if group member has access
+ const hasAccess = await permissionService.checkPermission({
+ userId: testUsers.editor._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testPromptGroup._id,
+ requiredPermission: PermissionBits.EDIT,
+ });
+
+ expect(hasAccess).toBe(true);
+
+ // Check that non-member doesn't have access
+ const nonMemberAccess = await permissionService.checkPermission({
+ userId: testUsers.viewer._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testPromptGroup._id,
+ requiredPermission: PermissionBits.EDIT,
+ });
+
+ expect(nonMemberAccess).toBe(false);
+ });
+ });
+
+ describe('Public Access', () => {
+ let publicPromptGroup, privatePromptGroup;
+
+ beforeEach(async () => {
+ // Create separate prompt groups for public and private access
+ publicPromptGroup = await PromptGroup.create({
+ name: 'Public Access Test Group',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ });
+
+ privatePromptGroup = await PromptGroup.create({
+ name: 'Private Access Test Group',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ });
+
+ // Create prompts in their respective groups
+ await Prompt.create({
+ prompt: 'Public prompt',
+ name: 'Public',
+ author: testUsers.owner._id,
+ groupId: publicPromptGroup._id,
+ type: 'text',
+ });
+
+ await Prompt.create({
+ prompt: 'Private prompt',
+ name: 'Private',
+ author: testUsers.owner._id,
+ groupId: privatePromptGroup._id,
+ type: 'text',
+ });
+
+ // Grant public view access to publicPromptGroup
+ await permissionService.grantPermission({
+ principalType: PrincipalType.PUBLIC,
+ principalId: null,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: publicPromptGroup._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
+ grantedBy: testUsers.owner._id,
+ });
+
+ // Grant only owner access to privatePromptGroup
+ await permissionService.grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: privatePromptGroup._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+ });
+
+ afterEach(async () => {
+ await Prompt.deleteMany({});
+ await PromptGroup.deleteMany({});
+ await AclEntry.deleteMany({});
+ });
+
+ it('public prompt should be accessible to any user', async () => {
+ const hasAccess = await permissionService.checkPermission({
+ userId: testUsers.noAccess._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: publicPromptGroup._id,
+ requiredPermission: PermissionBits.VIEW,
+ includePublic: true,
+ });
+
+ expect(hasAccess).toBe(true);
+ });
+
+ it('private prompt should not be accessible to unauthorized users', async () => {
+ const hasAccess = await permissionService.checkPermission({
+ userId: testUsers.noAccess._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: privatePromptGroup._id,
+ requiredPermission: PermissionBits.VIEW,
+ includePublic: true,
+ });
+
+ expect(hasAccess).toBe(false);
+ });
+ });
+
+ describe('Prompt Deletion', () => {
+ let testPromptGroup;
+
+ it('should remove ACL entries when prompt is deleted', async () => {
+ testPromptGroup = await PromptGroup.create({
+ name: 'Deletion Test Group',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ });
+
+ const prompt = await promptFns.savePrompt({
+ author: testUsers.owner._id,
+ prompt: {
+ prompt: 'To be deleted',
+ name: 'Delete Test',
+ groupId: testPromptGroup._id,
+ type: 'text',
+ },
+ });
+
+ // Check if savePrompt returned an error
+ if (!prompt || !prompt.prompt) {
+ throw new Error(`Failed to save prompt: ${prompt?.message || 'Unknown error'}`);
+ }
+
+ const testPromptId = prompt.prompt._id;
+ const promptGroupId = testPromptGroup._id;
+
+ // Grant permission
+ await permissionService.grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testPromptGroup._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+
+ // Verify ACL entry exists
+ const beforeDelete = await AclEntry.find({
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testPromptGroup._id,
+ });
+ expect(beforeDelete).toHaveLength(1);
+
+ // Delete the prompt
+ await promptFns.deletePrompt({
+ promptId: testPromptId,
+ groupId: promptGroupId,
+ author: testUsers.owner._id,
+ role: SystemRoles.USER,
+ });
+
+ // Verify ACL entries are removed
+ const aclEntries = await AclEntry.find({
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testPromptGroup._id,
+ });
+
+ expect(aclEntries).toHaveLength(0);
+ });
+ });
+
+ describe('Backwards Compatibility', () => {
+ it('should handle prompts without ACL entries gracefully', async () => {
+ // Create a prompt group first
+ const promptGroup = await PromptGroup.create({
+ name: 'Legacy Test Group',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ });
+
+ // Create a prompt without ACL entries (legacy prompt)
+ const legacyPrompt = await Prompt.create({
+ prompt: 'Legacy prompt without ACL',
+ name: 'Legacy',
+ author: testUsers.owner._id,
+ groupId: promptGroup._id,
+ type: 'text',
+ });
+
+ // The system should handle this gracefully
+ const prompt = await promptFns.getPrompt({ _id: legacyPrompt._id });
+ expect(prompt).toBeTruthy();
+ expect(prompt._id.toString()).toBe(legacyPrompt._id.toString());
+ });
+ });
+});
diff --git a/api/models/PromptGroupMigration.spec.js b/api/models/PromptGroupMigration.spec.js
new file mode 100644
index 0000000000..f568012cb3
--- /dev/null
+++ b/api/models/PromptGroupMigration.spec.js
@@ -0,0 +1,280 @@
+const mongoose = require('mongoose');
+const { ObjectId } = require('mongodb');
+const { logger } = require('@librechat/data-schemas');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const {
+ Constants,
+ ResourceType,
+ AccessRoleIds,
+ PrincipalType,
+ PrincipalModel,
+ PermissionBits,
+} = require('librechat-data-provider');
+
+// Mock the config/connect module to prevent connection attempts during tests
+jest.mock('../../config/connect', () => jest.fn().mockResolvedValue(true));
+
+// Disable console for tests
+logger.silent = true;
+
+describe('PromptGroup Migration Script', () => {
+ let mongoServer;
+ let Prompt, PromptGroup, AclEntry, AccessRole, User, Project;
+ let migrateToPromptGroupPermissions;
+ let testOwner, testProject;
+ let ownerRole, viewerRole;
+
+ beforeAll(async () => {
+ // Set up MongoDB memory server
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+
+ // Initialize models
+ const dbModels = require('~/db/models');
+ Prompt = dbModels.Prompt;
+ PromptGroup = dbModels.PromptGroup;
+ AclEntry = dbModels.AclEntry;
+ AccessRole = dbModels.AccessRole;
+ User = dbModels.User;
+ Project = dbModels.Project;
+
+ // Create test user
+ testOwner = await User.create({
+ name: 'Test Owner',
+ email: 'owner@test.com',
+ role: 'USER',
+ });
+
+ // Create test project with the proper name
+ const projectName = Constants.GLOBAL_PROJECT_NAME || 'instance';
+ testProject = await Project.create({
+ name: projectName,
+ description: 'Global project',
+ promptGroupIds: [],
+ });
+
+ // Create promptGroup access roles
+ ownerRole = await AccessRole.create({
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ name: 'Owner',
+ description: 'Full control over promptGroups',
+ resourceType: ResourceType.PROMPTGROUP,
+ permBits:
+ PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
+ });
+
+ viewerRole = await AccessRole.create({
+ accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
+ name: 'Viewer',
+ description: 'Can view promptGroups',
+ resourceType: ResourceType.PROMPTGROUP,
+ permBits: PermissionBits.VIEW,
+ });
+
+ await AccessRole.create({
+ accessRoleId: AccessRoleIds.PROMPTGROUP_EDITOR,
+ name: 'Editor',
+ description: 'Can view and edit promptGroups',
+ resourceType: ResourceType.PROMPTGROUP,
+ permBits: PermissionBits.VIEW | PermissionBits.EDIT,
+ });
+
+ // Import migration function
+ const migration = require('../../config/migrate-prompt-permissions');
+ migrateToPromptGroupPermissions = migration.migrateToPromptGroupPermissions;
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ // Clean up before each test
+ await Prompt.deleteMany({});
+ await PromptGroup.deleteMany({});
+ await AclEntry.deleteMany({});
+ // Reset the project's promptGroupIds array
+ testProject.promptGroupIds = [];
+ await testProject.save();
+ });
+
+ it('should categorize promptGroups correctly in dry run', async () => {
+ // Create global prompt group (in Global project)
+ const globalPromptGroup = await PromptGroup.create({
+ name: 'Global Group',
+ author: testOwner._id,
+ authorName: testOwner.name,
+ productionId: new ObjectId(),
+ });
+
+ // Create private prompt group (not in any project)
+ await PromptGroup.create({
+ name: 'Private Group',
+ author: testOwner._id,
+ authorName: testOwner.name,
+ productionId: new ObjectId(),
+ });
+
+ // Add global group to project's promptGroupIds array
+ testProject.promptGroupIds = [globalPromptGroup._id];
+ await testProject.save();
+
+ const result = await migrateToPromptGroupPermissions({ dryRun: true });
+
+ expect(result.dryRun).toBe(true);
+ expect(result.summary.total).toBe(2);
+ expect(result.summary.globalViewAccess).toBe(1);
+ expect(result.summary.privateGroups).toBe(1);
+ });
+
+ it('should grant appropriate permissions during migration', async () => {
+ // Create prompt groups
+ const globalPromptGroup = await PromptGroup.create({
+ name: 'Global Group',
+ author: testOwner._id,
+ authorName: testOwner.name,
+ productionId: new ObjectId(),
+ });
+
+ const privatePromptGroup = await PromptGroup.create({
+ name: 'Private Group',
+ author: testOwner._id,
+ authorName: testOwner.name,
+ productionId: new ObjectId(),
+ });
+
+ // Add global group to project's promptGroupIds array
+ testProject.promptGroupIds = [globalPromptGroup._id];
+ await testProject.save();
+
+ const result = await migrateToPromptGroupPermissions({ dryRun: false });
+
+ expect(result.migrated).toBe(2);
+ expect(result.errors).toBe(0);
+ expect(result.ownerGrants).toBe(2);
+ expect(result.publicViewGrants).toBe(1);
+
+ // Check global promptGroup permissions
+ const globalOwnerEntry = await AclEntry.findOne({
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: globalPromptGroup._id,
+ principalType: PrincipalType.USER,
+ principalId: testOwner._id,
+ });
+ expect(globalOwnerEntry).toBeTruthy();
+ expect(globalOwnerEntry.permBits).toBe(ownerRole.permBits);
+
+ const globalPublicEntry = await AclEntry.findOne({
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: globalPromptGroup._id,
+ principalType: PrincipalType.PUBLIC,
+ });
+ expect(globalPublicEntry).toBeTruthy();
+ expect(globalPublicEntry.permBits).toBe(viewerRole.permBits);
+
+ // Check private promptGroup permissions
+ const privateOwnerEntry = await AclEntry.findOne({
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: privatePromptGroup._id,
+ principalType: PrincipalType.USER,
+ principalId: testOwner._id,
+ });
+ expect(privateOwnerEntry).toBeTruthy();
+ expect(privateOwnerEntry.permBits).toBe(ownerRole.permBits);
+
+ const privatePublicEntry = await AclEntry.findOne({
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: privatePromptGroup._id,
+ principalType: PrincipalType.PUBLIC,
+ });
+ expect(privatePublicEntry).toBeNull();
+ });
+
+ it('should skip promptGroups that already have ACL entries', async () => {
+ // Create prompt groups
+ const promptGroup1 = await PromptGroup.create({
+ name: 'Group 1',
+ author: testOwner._id,
+ authorName: testOwner.name,
+ productionId: new ObjectId(),
+ });
+
+ const promptGroup2 = await PromptGroup.create({
+ name: 'Group 2',
+ author: testOwner._id,
+ authorName: testOwner.name,
+ productionId: new ObjectId(),
+ });
+
+ // Grant permission to one promptGroup manually (simulating it already has ACL)
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testOwner._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: promptGroup1._id,
+ permBits: ownerRole.permBits,
+ roleId: ownerRole._id,
+ grantedBy: testOwner._id,
+ grantedAt: new Date(),
+ });
+
+ const result = await migrateToPromptGroupPermissions({ dryRun: false });
+
+ // Should only migrate promptGroup2, skip promptGroup1
+ expect(result.migrated).toBe(1);
+ expect(result.errors).toBe(0);
+
+ // Verify promptGroup2 now has permissions
+ const group2Entry = await AclEntry.findOne({
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: promptGroup2._id,
+ });
+ expect(group2Entry).toBeTruthy();
+ });
+
+ it('should handle promptGroups with prompts correctly', async () => {
+ // Create a promptGroup with some prompts
+ const promptGroup = await PromptGroup.create({
+ name: 'Group with Prompts',
+ author: testOwner._id,
+ authorName: testOwner.name,
+ productionId: new ObjectId(),
+ });
+
+ // Create some prompts in this group
+ await Prompt.create({
+ prompt: 'First prompt',
+ author: testOwner._id,
+ groupId: promptGroup._id,
+ type: 'text',
+ });
+
+ await Prompt.create({
+ prompt: 'Second prompt',
+ author: testOwner._id,
+ groupId: promptGroup._id,
+ type: 'text',
+ });
+
+ const result = await migrateToPromptGroupPermissions({ dryRun: false });
+
+ expect(result.migrated).toBe(1);
+ expect(result.errors).toBe(0);
+
+ // Verify the promptGroup has permissions
+ const groupEntry = await AclEntry.findOne({
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: promptGroup._id,
+ });
+ expect(groupEntry).toBeTruthy();
+
+ // Verify no prompt-level permissions were created
+ const promptEntries = await AclEntry.find({
+ resourceType: 'prompt',
+ });
+ expect(promptEntries).toHaveLength(0);
+ });
+});
diff --git a/api/models/Role.js b/api/models/Role.js
new file mode 100644
index 0000000000..1766dc9b08
--- /dev/null
+++ b/api/models/Role.js
@@ -0,0 +1,256 @@
+const {
+ CacheKeys,
+ SystemRoles,
+ roleDefaults,
+ permissionsSchema,
+ removeNullishValues,
+} = require('librechat-data-provider');
+const { logger } = require('@librechat/data-schemas');
+const getLogStores = require('~/cache/getLogStores');
+const { Role } = require('~/db/models');
+
+/**
+ * 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.
+ *
+ * @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} Role document.
+ */
+const getRoleByName = async function (roleName, fieldsToSelect = null) {
+ const cache = getLogStores(CacheKeys.ROLES);
+ try {
+ const cachedRole = await cache.get(roleName);
+ if (cachedRole) {
+ return cachedRole;
+ }
+ let query = Role.findOne({ name: roleName });
+ if (fieldsToSelect) {
+ query = query.select(fieldsToSelect);
+ }
+ let role = await query.lean().exec();
+
+ if (!role && SystemRoles[roleName]) {
+ role = await new Role(roleDefaults[roleName]).save();
+ await cache.set(roleName, role);
+ return role.toObject();
+ }
+ await cache.set(roleName, role);
+ return role;
+ } catch (error) {
+ throw new Error(`Failed to retrieve or create role: ${error.message}`);
+ }
+};
+
+/**
+ * Update role values by name.
+ *
+ * @param {string} roleName - The name of the role to update.
+ * @param {Partial} updates - The fields to update.
+ * @returns {Promise} Updated role document.
+ */
+const updateRoleByName = async function (roleName, updates) {
+ const cache = getLogStores(CacheKeys.ROLES);
+ try {
+ const role = await Role.findOneAndUpdate(
+ { name: roleName },
+ { $set: updates },
+ { new: true, lean: true },
+ )
+ .select('-__v')
+ .lean()
+ .exec();
+ await cache.set(roleName, role);
+ return role;
+ } catch (error) {
+ throw new Error(`Failed to update role: ${error.message}`);
+ }
+};
+
+/**
+ * Updates access permissions for a specific role and multiple permission types.
+ * @param {string} roleName - The role to update.
+ * @param {Object.>} permissionsUpdate - Permissions to update and their values.
+ * @param {IRole} [roleData] - Optional role data to use instead of fetching from the database.
+ */
+async function updateAccessPermissions(roleName, permissionsUpdate, roleData) {
+ // Filter and clean the permission updates based on our schema definition.
+ const updates = {};
+ for (const [permissionType, permissions] of Object.entries(permissionsUpdate)) {
+ if (permissionsSchema.shape && permissionsSchema.shape[permissionType]) {
+ updates[permissionType] = removeNullishValues(permissions);
+ }
+ }
+ if (!Object.keys(updates).length) {
+ return;
+ }
+
+ try {
+ const role = roleData ?? (await getRoleByName(roleName));
+ if (!role) {
+ return;
+ }
+
+ 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;
+ }
+ }
+
+ for (const [permissionType, permissions] of Object.entries(updates)) {
+ const currentTypePermissions = currentPermissions[permissionType] || {};
+ updatedPermissions[permissionType] = { ...currentTypePermissions };
+
+ for (const [permission, value] of Object.entries(permissions)) {
+ if (currentTypePermissions[permission] !== value) {
+ updatedPermissions[permissionType][permission] = value;
+ hasChanges = true;
+ logger.info(
+ `Updating '${roleName}' role permission '${permissionType}' '${permission}' from ${currentTypePermissions[permission]} to: ${value}`,
+ );
+ }
+ }
+ }
+
+ if (hasChanges) {
+ 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`);
+ }
+ } catch (error) {
+ logger.error(`Failed to update ${roleName} role permissions:`, error);
+ }
+}
+
+/**
+ * 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 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 = {
+ getRoleByName,
+ updateRoleByName,
+ migrateRoleSchema,
+ updateAccessPermissions,
+};
diff --git a/api/models/Role.spec.js b/api/models/Role.spec.js
new file mode 100644
index 0000000000..c344f719dd
--- /dev/null
+++ b/api/models/Role.spec.js
@@ -0,0 +1,405 @@
+const mongoose = require('mongoose');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const {
+ SystemRoles,
+ Permissions,
+ roleDefaults,
+ PermissionTypes,
+} = require('librechat-data-provider');
+const { getRoleByName, updateAccessPermissions } = require('~/models/Role');
+const getLogStores = require('~/cache/getLogStores');
+const { initializeRoles } = require('~/models');
+const { Role } = require('~/db/models');
+
+// Mock the cache
+jest.mock('~/cache/getLogStores', () =>
+ jest.fn().mockReturnValue({
+ get: jest.fn(),
+ set: jest.fn(),
+ del: jest.fn(),
+ }),
+);
+
+let mongoServer;
+
+beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+});
+
+afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+});
+
+beforeEach(async () => {
+ await Role.deleteMany({});
+ getLogStores.mockClear();
+});
+
+describe('updateAccessPermissions', () => {
+ it('should update permissions when changes are needed', async () => {
+ await new Role({
+ name: SystemRoles.USER,
+ permissions: {
+ [PermissionTypes.PROMPTS]: {
+ CREATE: true,
+ USE: true,
+ SHARED_GLOBAL: false,
+ },
+ },
+ }).save();
+
+ await updateAccessPermissions(SystemRoles.USER, {
+ [PermissionTypes.PROMPTS]: {
+ CREATE: true,
+ USE: true,
+ SHARED_GLOBAL: true,
+ },
+ });
+
+ const updatedRole = await getRoleByName(SystemRoles.USER);
+ expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
+ CREATE: true,
+ USE: true,
+ SHARED_GLOBAL: true,
+ });
+ });
+
+ it('should not update permissions when no changes are needed', async () => {
+ await new Role({
+ name: SystemRoles.USER,
+ permissions: {
+ [PermissionTypes.PROMPTS]: {
+ CREATE: true,
+ USE: true,
+ SHARED_GLOBAL: false,
+ },
+ },
+ }).save();
+
+ await updateAccessPermissions(SystemRoles.USER, {
+ [PermissionTypes.PROMPTS]: {
+ CREATE: true,
+ USE: true,
+ SHARED_GLOBAL: false,
+ },
+ });
+
+ const updatedRole = await getRoleByName(SystemRoles.USER);
+ expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
+ CREATE: true,
+ USE: true,
+ SHARED_GLOBAL: false,
+ });
+ });
+
+ it('should handle non-existent roles', async () => {
+ await updateAccessPermissions('NON_EXISTENT_ROLE', {
+ [PermissionTypes.PROMPTS]: { CREATE: true },
+ });
+ const role = await Role.findOne({ name: 'NON_EXISTENT_ROLE' });
+ expect(role).toBeNull();
+ });
+
+ it('should update only specified permissions', async () => {
+ await new Role({
+ name: SystemRoles.USER,
+ permissions: {
+ [PermissionTypes.PROMPTS]: {
+ CREATE: true,
+ USE: true,
+ SHARED_GLOBAL: false,
+ },
+ },
+ }).save();
+
+ await updateAccessPermissions(SystemRoles.USER, {
+ [PermissionTypes.PROMPTS]: { SHARED_GLOBAL: true },
+ });
+
+ const updatedRole = await getRoleByName(SystemRoles.USER);
+ expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
+ CREATE: true,
+ USE: true,
+ SHARED_GLOBAL: true,
+ });
+ });
+
+ it('should handle partial updates', async () => {
+ await new Role({
+ name: SystemRoles.USER,
+ permissions: {
+ [PermissionTypes.PROMPTS]: {
+ CREATE: true,
+ USE: true,
+ SHARED_GLOBAL: false,
+ },
+ },
+ }).save();
+
+ await updateAccessPermissions(SystemRoles.USER, {
+ [PermissionTypes.PROMPTS]: { USE: false },
+ });
+
+ const updatedRole = await getRoleByName(SystemRoles.USER);
+ expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
+ CREATE: true,
+ USE: false,
+ SHARED_GLOBAL: false,
+ });
+ });
+
+ it('should update multiple permission types at once', async () => {
+ await new Role({
+ name: SystemRoles.USER,
+ permissions: {
+ [PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARED_GLOBAL: false },
+ [PermissionTypes.BOOKMARKS]: { USE: true },
+ },
+ }).save();
+
+ await updateAccessPermissions(SystemRoles.USER, {
+ [PermissionTypes.PROMPTS]: { USE: false, SHARED_GLOBAL: true },
+ [PermissionTypes.BOOKMARKS]: { USE: false },
+ });
+
+ const updatedRole = await getRoleByName(SystemRoles.USER);
+ expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
+ CREATE: true,
+ USE: false,
+ SHARED_GLOBAL: true,
+ });
+ expect(updatedRole.permissions[PermissionTypes.BOOKMARKS]).toEqual({ USE: false });
+ });
+
+ it('should handle updates for a single permission type', async () => {
+ await new Role({
+ name: SystemRoles.USER,
+ permissions: {
+ [PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARED_GLOBAL: false },
+ },
+ }).save();
+
+ await updateAccessPermissions(SystemRoles.USER, {
+ [PermissionTypes.PROMPTS]: { USE: false, SHARED_GLOBAL: true },
+ });
+
+ const updatedRole = await getRoleByName(SystemRoles.USER);
+ expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
+ CREATE: true,
+ USE: false,
+ SHARED_GLOBAL: true,
+ });
+ });
+
+ it('should update MULTI_CONVO permissions', async () => {
+ await new Role({
+ name: SystemRoles.USER,
+ permissions: {
+ [PermissionTypes.MULTI_CONVO]: { USE: false },
+ },
+ }).save();
+
+ await updateAccessPermissions(SystemRoles.USER, {
+ [PermissionTypes.MULTI_CONVO]: { 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,
+ permissions: {
+ [PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARED_GLOBAL: false },
+ [PermissionTypes.MULTI_CONVO]: { USE: false },
+ },
+ }).save();
+
+ await updateAccessPermissions(SystemRoles.USER, {
+ [PermissionTypes.PROMPTS]: { SHARED_GLOBAL: true },
+ [PermissionTypes.MULTI_CONVO]: { USE: true },
+ });
+
+ const updatedRole = await getRoleByName(SystemRoles.USER);
+ expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
+ CREATE: true,
+ USE: true,
+ SHARED_GLOBAL: 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,
+ permissions: {
+ [PermissionTypes.MULTI_CONVO]: { USE: true },
+ },
+ }).save();
+
+ await updateAccessPermissions(SystemRoles.USER, {
+ [PermissionTypes.MULTI_CONVO]: { USE: true },
+ });
+
+ const updatedRole = await getRoleByName(SystemRoles.USER);
+ expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]).toEqual({ USE: true });
+ });
+});
+
+describe('initializeRoles', () => {
+ beforeEach(async () => {
+ await Role.deleteMany({});
+ });
+
+ it('should create default roles if they do not exist', async () => {
+ await initializeRoles();
+
+ const adminRole = await getRoleByName(SystemRoles.ADMIN);
+ const userRole = await getRoleByName(SystemRoles.USER);
+
+ expect(adminRole).toBeTruthy();
+ expect(userRole).toBeTruthy();
+
+ // Check if all permission types exist in the permissions field
+ Object.values(PermissionTypes).forEach((permType) => {
+ expect(adminRole.permissions[permType]).toBeDefined();
+ expect(userRole.permissions[permType]).toBeDefined();
+ });
+
+ // 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,
+ 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 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,
+ 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 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 () => {
+ await initializeRoles();
+ await initializeRoles();
+
+ const adminRoles = await Role.find({ name: SystemRoles.ADMIN });
+ const userRoles = await Role.find({ name: SystemRoles.USER });
+
+ expect(adminRoles).toHaveLength(1);
+ expect(userRoles).toHaveLength(1);
+
+ const adminPerms = adminRoles[0].toObject().permissions;
+ const userPerms = userRoles[0].toObject().permissions;
+ Object.values(PermissionTypes).forEach((permType) => {
+ expect(adminPerms[permType]).toBeDefined();
+ expect(userPerms[permType]).toBeDefined();
+ });
+ });
+
+ it('should update roles with missing permission types from roleDefaults', async () => {
+ const partialAdminRole = {
+ name: SystemRoles.ADMIN,
+ permissions: {
+ [PermissionTypes.PROMPTS]: {
+ [Permissions.USE]: false,
+ [Permissions.CREATE]: false,
+ [Permissions.SHARED_GLOBAL]: false,
+ },
+ [PermissionTypes.BOOKMARKS]:
+ roleDefaults[SystemRoles.ADMIN].permissions[PermissionTypes.BOOKMARKS],
+ },
+ };
+
+ await new Role(partialAdminRole).save();
+ await initializeRoles();
+
+ 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 getRoleByName(SystemRoles.ADMIN);
+ const userRole = await getRoleByName(SystemRoles.USER);
+
+ 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.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,
+ 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 getRoleByName(SystemRoles.USER);
+ expect(userRole.permissions[PermissionTypes.MULTI_CONVO]).toBeDefined();
+ expect(userRole.permissions[PermissionTypes.MULTI_CONVO].USE).toBeDefined();
+ });
+});
diff --git a/api/models/ToolCall.js b/api/models/ToolCall.js
new file mode 100644
index 0000000000..689386114b
--- /dev/null
+++ b/api/models/ToolCall.js
@@ -0,0 +1,96 @@
+const { ToolCall } = require('~/db/models');
+
+/**
+ * Create a new tool call
+ * @param {IToolCallData} toolCallData - The tool call data
+ * @returns {Promise} The created tool call document
+ */
+async function createToolCall(toolCallData) {
+ try {
+ return await ToolCall.create(toolCallData);
+ } catch (error) {
+ throw new Error(`Error creating tool call: ${error.message}`);
+ }
+}
+
+/**
+ * Get a tool call by ID
+ * @param {string} id - The tool call document ID
+ * @returns {Promise} The tool call document or null if not found
+ */
+async function getToolCallById(id) {
+ try {
+ return await ToolCall.findById(id).lean();
+ } catch (error) {
+ throw new Error(`Error fetching tool call: ${error.message}`);
+ }
+}
+
+/**
+ * Get tool calls by message ID and user
+ * @param {string} messageId - The message ID
+ * @param {string} userId - The user's ObjectId
+ * @returns {Promise} Array of tool call documents
+ */
+async function getToolCallsByMessage(messageId, userId) {
+ try {
+ return await ToolCall.find({ messageId, user: userId }).lean();
+ } catch (error) {
+ throw new Error(`Error fetching tool calls: ${error.message}`);
+ }
+}
+
+/**
+ * Get tool calls by conversation ID and user
+ * @param {string} conversationId - The conversation ID
+ * @param {string} userId - The user's ObjectId
+ * @returns {Promise} Array of tool call documents
+ */
+async function getToolCallsByConvo(conversationId, userId) {
+ try {
+ return await ToolCall.find({ conversationId, user: userId }).lean();
+ } catch (error) {
+ throw new Error(`Error fetching tool calls: ${error.message}`);
+ }
+}
+
+/**
+ * Update a tool call
+ * @param {string} id - The tool call document ID
+ * @param {Partial} updateData - The data to update
+ * @returns {Promise} The updated tool call document or null if not found
+ */
+async function updateToolCall(id, updateData) {
+ try {
+ return await ToolCall.findByIdAndUpdate(id, updateData, { new: true }).lean();
+ } catch (error) {
+ throw new Error(`Error updating tool call: ${error.message}`);
+ }
+}
+
+/**
+ * Delete a tool call
+ * @param {string} userId - The related user's ObjectId
+ * @param {string} [conversationId] - The tool call conversation ID
+ * @returns {Promise<{ ok?: number; n?: number; deletedCount?: number }>} The result of the delete operation
+ */
+async function deleteToolCalls(userId, conversationId) {
+ try {
+ const query = { user: userId };
+ if (conversationId) {
+ query.conversationId = conversationId;
+ }
+ return await ToolCall.deleteMany(query);
+ } catch (error) {
+ throw new Error(`Error deleting tool call: ${error.message}`);
+ }
+}
+
+module.exports = {
+ createToolCall,
+ updateToolCall,
+ deleteToolCalls,
+ getToolCallById,
+ getToolCallsByConvo,
+ getToolCallsByMessage,
+};
diff --git a/api/models/Transaction.js b/api/models/Transaction.js
new file mode 100644
index 0000000000..5fa20f1ddf
--- /dev/null
+++ b/api/models/Transaction.js
@@ -0,0 +1,346 @@
+const { logger } = require('@librechat/data-schemas');
+const { getMultiplier, getCacheMultiplier } = require('./tx');
+const { Transaction, Balance } = require('~/db/models');
+
+const cancelRate = 1.15;
+
+/**
+ * Updates a user's token balance based on a transaction using optimistic concurrency control
+ * without schema changes. Compatible with DocumentDB.
+ * @async
+ * @function
+ * @param {Object} params - The function parameters.
+ * @param {string|mongoose.Types.ObjectId} params.user - The user ID.
+ * @param {number} params.incrementValue - The value to increment the balance by (can be negative).
+ * @param {import('mongoose').UpdateQuery['$set']} [params.setValues] - Optional additional fields to set.
+ * @returns {Promise} Returns the updated balance document (lean).
+ * @throws {Error} Throws an error if the update fails after multiple retries.
+ */
+const updateBalance = async ({ user, incrementValue, setValues }) => {
+ let maxRetries = 10; // Number of times to retry on conflict
+ let delay = 50; // Initial retry delay in ms
+ let lastError = null;
+
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
+ let currentBalanceDoc;
+ try {
+ // 1. Read the current document state
+ currentBalanceDoc = await Balance.findOne({ user }).lean();
+ const currentCredits = currentBalanceDoc ? currentBalanceDoc.tokenCredits : 0;
+
+ // 2. Calculate the desired new state
+ const potentialNewCredits = currentCredits + incrementValue;
+ const newCredits = Math.max(0, potentialNewCredits); // Ensure balance doesn't go below zero
+
+ // 3. Prepare the update payload
+ const updatePayload = {
+ $set: {
+ tokenCredits: newCredits,
+ ...(setValues || {}), // Merge other values to set
+ },
+ };
+
+ // 4. Attempt the conditional update or upsert
+ let updatedBalance = null;
+ if (currentBalanceDoc) {
+ // --- Document Exists: Perform Conditional Update ---
+ // Try to update only if the tokenCredits match the value we read (currentCredits)
+ updatedBalance = await Balance.findOneAndUpdate(
+ {
+ user: user,
+ tokenCredits: currentCredits, // Optimistic lock: condition based on the read value
+ },
+ updatePayload,
+ {
+ new: true, // Return the modified document
+ // lean: true, // .lean() is applied after query execution in Mongoose >= 6
+ },
+ ).lean(); // Use lean() for plain JS object
+
+ if (updatedBalance) {
+ // Success! The update was applied based on the expected current state.
+ return updatedBalance;
+ }
+ // If updatedBalance is null, it means tokenCredits changed between read and write (conflict).
+ lastError = new Error(`Concurrency conflict for user ${user} on attempt ${attempt}.`);
+ // Proceed to retry logic below.
+ } else {
+ // --- Document Does Not Exist: Perform Conditional Upsert ---
+ // Try to insert the document, but only if it still doesn't exist.
+ // Using tokenCredits: {$exists: false} helps prevent race conditions where
+ // another process creates the doc between our findOne and findOneAndUpdate.
+ try {
+ updatedBalance = await Balance.findOneAndUpdate(
+ {
+ user: user,
+ // Attempt to match only if the document doesn't exist OR was just created
+ // without tokenCredits (less likely but possible). A simple { user } filter
+ // might also work, relying on the retry for conflicts.
+ // Let's use a simpler filter and rely on retry for races.
+ // tokenCredits: { $exists: false } // This condition might be too strict if doc exists with 0 credits
+ },
+ updatePayload,
+ {
+ upsert: true, // Create if doesn't exist
+ new: true, // Return the created/updated document
+ // setDefaultsOnInsert: true, // Ensure schema defaults are applied on insert
+ // lean: true,
+ },
+ ).lean();
+
+ if (updatedBalance) {
+ // Upsert succeeded (likely created the document)
+ return updatedBalance;
+ }
+ // If null, potentially a rare race condition during upsert. Retry should handle it.
+ lastError = new Error(
+ `Upsert race condition suspected for user ${user} on attempt ${attempt}.`,
+ );
+ } catch (error) {
+ if (error.code === 11000) {
+ // E11000 duplicate key error on index
+ // This means another process created the document *just* before our upsert.
+ // It's a concurrency conflict during creation. We should retry.
+ lastError = error; // Store the error
+ // Proceed to retry logic below.
+ } else {
+ // Different error, rethrow
+ throw error;
+ }
+ }
+ } // End if/else (document exists?)
+ } catch (error) {
+ // Catch errors from findOne or unexpected findOneAndUpdate errors
+ logger.error(`[updateBalance] Error during attempt ${attempt} for user ${user}:`, error);
+ lastError = error; // Store the error
+ // Consider stopping retries for non-transient errors, but for now, we retry.
+ }
+
+ // If we reached here, it means the update failed (conflict or error), wait and retry
+ if (attempt < maxRetries) {
+ const jitter = Math.random() * delay * 0.5; // Add jitter to delay
+ await new Promise((resolve) => setTimeout(resolve, delay + jitter));
+ delay = Math.min(delay * 2, 2000); // Exponential backoff with cap
+ }
+ } // End for loop (retries)
+
+ // If loop finishes without success, throw the last encountered error or a generic one
+ logger.error(
+ `[updateBalance] Failed to update balance for user ${user} after ${maxRetries} attempts.`,
+ );
+ throw (
+ lastError ||
+ new Error(
+ `Failed to update balance for user ${user} after maximum retries due to persistent conflicts.`,
+ )
+ );
+};
+
+/** Method to calculate and set the tokenValue for a transaction */
+function calculateTokenValue(txn) {
+ if (!txn.valueKey || !txn.tokenType) {
+ txn.tokenValue = txn.rawAmount;
+ }
+ const { valueKey, tokenType, model, endpointTokenConfig } = txn;
+ const multiplier = Math.abs(getMultiplier({ valueKey, tokenType, model, endpointTokenConfig }));
+ txn.rate = multiplier;
+ txn.tokenValue = txn.rawAmount * multiplier;
+ if (txn.context && txn.tokenType === 'completion' && txn.context === 'incomplete') {
+ txn.tokenValue = Math.ceil(txn.tokenValue * cancelRate);
+ txn.rate *= cancelRate;
+ }
+}
+
+/**
+ * New static method to create an auto-refill transaction that does NOT trigger a balance update.
+ * @param {object} txData - Transaction data.
+ * @param {string} txData.user - The user ID.
+ * @param {string} txData.tokenType - The type of token.
+ * @param {string} txData.context - The context of the transaction.
+ * @param {number} txData.rawAmount - The raw amount of tokens.
+ * @returns {Promise} - The created transaction.
+ */
+async function createAutoRefillTransaction(txData) {
+ if (txData.rawAmount != null && isNaN(txData.rawAmount)) {
+ return;
+ }
+ const transaction = new Transaction(txData);
+ transaction.endpointTokenConfig = txData.endpointTokenConfig;
+ calculateTokenValue(transaction);
+ await transaction.save();
+
+ const balanceResponse = await updateBalance({
+ user: transaction.user,
+ incrementValue: txData.rawAmount,
+ setValues: { lastRefill: new Date() },
+ });
+ const result = {
+ rate: transaction.rate,
+ user: transaction.user.toString(),
+ balance: balanceResponse.tokenCredits,
+ };
+ logger.debug('[Balance.check] Auto-refill performed', result);
+ result.transaction = transaction;
+ return result;
+}
+
+/**
+ * Static method to create a transaction and update the balance
+ * @param {txData} _txData - Transaction data.
+ */
+async function createTransaction(_txData) {
+ const { balance, transactions, ...txData } = _txData;
+ if (txData.rawAmount != null && isNaN(txData.rawAmount)) {
+ return;
+ }
+
+ if (transactions?.enabled === false) {
+ return;
+ }
+
+ const transaction = new Transaction(txData);
+ transaction.endpointTokenConfig = txData.endpointTokenConfig;
+ calculateTokenValue(transaction);
+
+ await transaction.save();
+ if (!balance?.enabled) {
+ return;
+ }
+
+ let incrementValue = transaction.tokenValue;
+ const balanceResponse = await updateBalance({
+ user: transaction.user,
+ incrementValue,
+ });
+
+ return {
+ rate: transaction.rate,
+ user: transaction.user.toString(),
+ balance: balanceResponse.tokenCredits,
+ [transaction.tokenType]: incrementValue,
+ };
+}
+
+/**
+ * Static method to create a structured transaction and update the balance
+ * @param {txData} _txData - Transaction data.
+ */
+async function createStructuredTransaction(_txData) {
+ const { balance, transactions, ...txData } = _txData;
+ if (transactions?.enabled === false) {
+ return;
+ }
+
+ const transaction = new Transaction({
+ ...txData,
+ endpointTokenConfig: txData.endpointTokenConfig,
+ });
+
+ calculateStructuredTokenValue(transaction);
+
+ await transaction.save();
+
+ if (!balance?.enabled) {
+ return;
+ }
+
+ let incrementValue = transaction.tokenValue;
+
+ const balanceResponse = await updateBalance({
+ user: transaction.user,
+ incrementValue,
+ });
+
+ return {
+ rate: transaction.rate,
+ user: transaction.user.toString(),
+ balance: balanceResponse.tokenCredits,
+ [transaction.tokenType]: incrementValue,
+ };
+}
+
+/** Method to calculate token value for structured tokens */
+function calculateStructuredTokenValue(txn) {
+ if (!txn.tokenType) {
+ txn.tokenValue = txn.rawAmount;
+ return;
+ }
+
+ const { model, endpointTokenConfig } = txn;
+
+ if (txn.tokenType === 'prompt') {
+ const inputMultiplier = getMultiplier({ tokenType: 'prompt', model, endpointTokenConfig });
+ const writeMultiplier =
+ getCacheMultiplier({ cacheType: 'write', model, endpointTokenConfig }) ?? inputMultiplier;
+ const readMultiplier =
+ getCacheMultiplier({ cacheType: 'read', model, endpointTokenConfig }) ?? inputMultiplier;
+
+ txn.rateDetail = {
+ input: inputMultiplier,
+ write: writeMultiplier,
+ read: readMultiplier,
+ };
+
+ const totalPromptTokens =
+ Math.abs(txn.inputTokens || 0) +
+ Math.abs(txn.writeTokens || 0) +
+ Math.abs(txn.readTokens || 0);
+
+ if (totalPromptTokens > 0) {
+ txn.rate =
+ (Math.abs(inputMultiplier * (txn.inputTokens || 0)) +
+ Math.abs(writeMultiplier * (txn.writeTokens || 0)) +
+ Math.abs(readMultiplier * (txn.readTokens || 0))) /
+ totalPromptTokens;
+ } else {
+ txn.rate = Math.abs(inputMultiplier); // Default to input rate if no tokens
+ }
+
+ txn.tokenValue = -(
+ Math.abs(txn.inputTokens || 0) * inputMultiplier +
+ Math.abs(txn.writeTokens || 0) * writeMultiplier +
+ Math.abs(txn.readTokens || 0) * readMultiplier
+ );
+
+ txn.rawAmount = -totalPromptTokens;
+ } else if (txn.tokenType === 'completion') {
+ const multiplier = getMultiplier({ tokenType: txn.tokenType, model, endpointTokenConfig });
+ txn.rate = Math.abs(multiplier);
+ txn.tokenValue = -Math.abs(txn.rawAmount) * multiplier;
+ txn.rawAmount = -Math.abs(txn.rawAmount);
+ }
+
+ if (txn.context && txn.tokenType === 'completion' && txn.context === 'incomplete') {
+ txn.tokenValue = Math.ceil(txn.tokenValue * cancelRate);
+ txn.rate *= cancelRate;
+ if (txn.rateDetail) {
+ txn.rateDetail = Object.fromEntries(
+ Object.entries(txn.rateDetail).map(([k, v]) => [k, v * cancelRate]),
+ );
+ }
+ }
+}
+
+/**
+ * Queries and retrieves transactions based on a given filter.
+ * @async
+ * @function getTransactions
+ * @param {Object} filter - MongoDB filter object to apply when querying transactions.
+ * @returns {Promise} A promise that resolves to an array of matched transactions.
+ * @throws {Error} Throws an error if querying the database fails.
+ */
+async function getTransactions(filter) {
+ try {
+ return await Transaction.find(filter).lean();
+ } catch (error) {
+ logger.error('Error querying transactions:', error);
+ throw error;
+ }
+}
+
+module.exports = {
+ getTransactions,
+ createTransaction,
+ createAutoRefillTransaction,
+ createStructuredTransaction,
+};
diff --git a/api/models/Transaction.spec.js b/api/models/Transaction.spec.js
new file mode 100644
index 0000000000..2df9fc67f2
--- /dev/null
+++ b/api/models/Transaction.spec.js
@@ -0,0 +1,566 @@
+const mongoose = require('mongoose');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { spendTokens, spendStructuredTokens } = require('./spendTokens');
+const { getMultiplier, getCacheMultiplier } = require('./tx');
+const { createTransaction, createStructuredTransaction } = require('./Transaction');
+const { Balance, Transaction } = require('~/db/models');
+
+let mongoServer;
+beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+});
+
+afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+});
+
+beforeEach(async () => {
+ await mongoose.connection.dropDatabase();
+});
+
+describe('Regular Token Spending Tests', () => {
+ test('Balance should decrease when spending tokens with spendTokens', async () => {
+ // Arrange
+ const userId = new mongoose.Types.ObjectId();
+ const initialBalance = 10000000; // $10.00
+ await Balance.create({ user: userId, tokenCredits: initialBalance });
+
+ const model = 'gpt-3.5-turbo';
+ const txData = {
+ user: userId,
+ conversationId: 'test-conversation-id',
+ model,
+ context: 'test',
+ endpointTokenConfig: null,
+ balance: { enabled: true },
+ };
+
+ const tokenUsage = {
+ promptTokens: 100,
+ completionTokens: 50,
+ };
+
+ // Act
+ await spendTokens(txData, tokenUsage);
+
+ // Assert
+ const updatedBalance = await Balance.findOne({ user: userId });
+ const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
+ const completionMultiplier = getMultiplier({ model, tokenType: 'completion' });
+ const expectedTotalCost = 100 * promptMultiplier + 50 * completionMultiplier;
+ const expectedBalance = initialBalance - expectedTotalCost;
+
+ expect(updatedBalance.tokenCredits).toBeCloseTo(expectedBalance, 0);
+ });
+
+ test('spendTokens should handle zero completion tokens', async () => {
+ // Arrange
+ const userId = new mongoose.Types.ObjectId();
+ const initialBalance = 10000000;
+ await Balance.create({ user: userId, tokenCredits: initialBalance });
+
+ const model = 'gpt-3.5-turbo';
+ const txData = {
+ user: userId,
+ conversationId: 'test-conversation-id',
+ model,
+ context: 'test',
+ endpointTokenConfig: null,
+ balance: { enabled: true },
+ };
+
+ const tokenUsage = {
+ promptTokens: 100,
+ completionTokens: 0,
+ };
+
+ // Act
+ await spendTokens(txData, tokenUsage);
+
+ // Assert
+ const updatedBalance = await Balance.findOne({ user: userId });
+ const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
+ const expectedCost = 100 * promptMultiplier;
+ expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
+ });
+
+ test('spendTokens should handle undefined token counts', async () => {
+ // Arrange
+ const userId = new mongoose.Types.ObjectId();
+ const initialBalance = 10000000;
+ await Balance.create({ user: userId, tokenCredits: initialBalance });
+
+ const model = 'gpt-3.5-turbo';
+ const txData = {
+ user: userId,
+ conversationId: 'test-conversation-id',
+ model,
+ context: 'test',
+ endpointTokenConfig: null,
+ balance: { enabled: true },
+ };
+
+ const tokenUsage = {};
+
+ // Act
+ const result = await spendTokens(txData, tokenUsage);
+
+ // Assert: No transaction should be created
+ expect(result).toBeUndefined();
+ });
+
+ test('spendTokens should handle only prompt tokens', async () => {
+ // Arrange
+ const userId = new mongoose.Types.ObjectId();
+ const initialBalance = 10000000;
+ await Balance.create({ user: userId, tokenCredits: initialBalance });
+
+ const model = 'gpt-3.5-turbo';
+ const txData = {
+ user: userId,
+ conversationId: 'test-conversation-id',
+ model,
+ context: 'test',
+ endpointTokenConfig: null,
+ balance: { enabled: true },
+ };
+
+ const tokenUsage = { promptTokens: 100 };
+
+ // Act
+ await spendTokens(txData, tokenUsage);
+
+ // Assert
+ const updatedBalance = await Balance.findOne({ user: userId });
+ const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
+ const expectedCost = 100 * promptMultiplier;
+ expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
+ });
+
+ test('spendTokens should not update balance when balance feature is disabled', async () => {
+ // Arrange: Balance config is now passed directly in txData
+ const userId = new mongoose.Types.ObjectId();
+ const initialBalance = 10000000;
+ await Balance.create({ user: userId, tokenCredits: initialBalance });
+
+ const model = 'gpt-3.5-turbo';
+ const txData = {
+ user: userId,
+ conversationId: 'test-conversation-id',
+ model,
+ context: 'test',
+ endpointTokenConfig: null,
+ balance: { enabled: false },
+ };
+
+ const tokenUsage = {
+ promptTokens: 100,
+ completionTokens: 50,
+ };
+
+ // Act
+ await spendTokens(txData, tokenUsage);
+
+ // Assert: Balance should remain unchanged.
+ const updatedBalance = await Balance.findOne({ user: userId });
+ expect(updatedBalance.tokenCredits).toBe(initialBalance);
+ });
+});
+
+describe('Structured Token Spending Tests', () => {
+ test('Balance should decrease and rawAmount should be set when spending a large number of structured tokens', async () => {
+ // Arrange
+ const userId = new mongoose.Types.ObjectId();
+ const initialBalance = 17613154.55; // $17.61
+ await Balance.create({ user: userId, tokenCredits: initialBalance });
+
+ const model = 'claude-3-5-sonnet';
+ const txData = {
+ user: userId,
+ conversationId: 'c23a18da-706c-470a-ac28-ec87ed065199',
+ model,
+ context: 'message',
+ endpointTokenConfig: null,
+ balance: { enabled: true },
+ };
+
+ const tokenUsage = {
+ promptTokens: {
+ input: 11,
+ write: 140522,
+ read: 0,
+ },
+ completionTokens: 5,
+ };
+
+ const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
+ const completionMultiplier = getMultiplier({ model, tokenType: 'completion' });
+ const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' });
+ const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' });
+
+ // Act
+ const result = await spendStructuredTokens(txData, tokenUsage);
+
+ // Calculate expected costs.
+ const expectedPromptCost =
+ tokenUsage.promptTokens.input * promptMultiplier +
+ tokenUsage.promptTokens.write * writeMultiplier +
+ tokenUsage.promptTokens.read * readMultiplier;
+ const expectedCompletionCost = tokenUsage.completionTokens * completionMultiplier;
+ const expectedTotalCost = expectedPromptCost + expectedCompletionCost;
+ const expectedBalance = initialBalance - expectedTotalCost;
+
+ // Assert
+ expect(result.completion.balance).toBeLessThan(initialBalance);
+ const allowedDifference = 100;
+ expect(Math.abs(result.completion.balance - expectedBalance)).toBeLessThan(allowedDifference);
+ const balanceDecrease = initialBalance - result.completion.balance;
+ expect(balanceDecrease).toBeCloseTo(expectedTotalCost, 0);
+
+ const expectedPromptTokenValue = -expectedPromptCost;
+ const expectedCompletionTokenValue = -expectedCompletionCost;
+ expect(result.prompt.prompt).toBeCloseTo(expectedPromptTokenValue, 1);
+ expect(result.completion.completion).toBe(expectedCompletionTokenValue);
+ });
+
+ test('should handle zero completion tokens in structured spending', async () => {
+ // Arrange
+ const userId = new mongoose.Types.ObjectId();
+ const initialBalance = 17613154.55;
+ await Balance.create({ user: userId, tokenCredits: initialBalance });
+
+ const model = 'claude-3-5-sonnet';
+ const txData = {
+ user: userId,
+ conversationId: 'test-convo',
+ model,
+ context: 'message',
+ balance: { enabled: true },
+ };
+
+ const tokenUsage = {
+ promptTokens: {
+ input: 10,
+ write: 100,
+ read: 5,
+ },
+ completionTokens: 0,
+ };
+
+ // Act
+ const result = await spendStructuredTokens(txData, tokenUsage);
+
+ // Assert
+ expect(result.prompt).toBeDefined();
+ expect(result.completion).toBeUndefined();
+ expect(result.prompt.prompt).toBeLessThan(0);
+ });
+
+ test('should handle only prompt tokens in structured spending', async () => {
+ // Arrange
+ const userId = new mongoose.Types.ObjectId();
+ const initialBalance = 17613154.55;
+ await Balance.create({ user: userId, tokenCredits: initialBalance });
+
+ const model = 'claude-3-5-sonnet';
+ const txData = {
+ user: userId,
+ conversationId: 'test-convo',
+ model,
+ context: 'message',
+ balance: { enabled: true },
+ };
+
+ const tokenUsage = {
+ promptTokens: {
+ input: 10,
+ write: 100,
+ read: 5,
+ },
+ };
+
+ // Act
+ const result = await spendStructuredTokens(txData, tokenUsage);
+
+ // Assert
+ expect(result.prompt).toBeDefined();
+ expect(result.completion).toBeUndefined();
+ expect(result.prompt.prompt).toBeLessThan(0);
+ });
+
+ test('should handle undefined token counts in structured spending', async () => {
+ // Arrange
+ const userId = new mongoose.Types.ObjectId();
+ const initialBalance = 17613154.55;
+ await Balance.create({ user: userId, tokenCredits: initialBalance });
+
+ const model = 'claude-3-5-sonnet';
+ const txData = {
+ user: userId,
+ conversationId: 'test-convo',
+ model,
+ context: 'message',
+ balance: { enabled: true },
+ };
+
+ const tokenUsage = {};
+
+ // Act
+ const result = await spendStructuredTokens(txData, tokenUsage);
+
+ // Assert
+ expect(result).toEqual({
+ prompt: undefined,
+ completion: undefined,
+ });
+ });
+
+ test('should handle incomplete context for completion tokens', async () => {
+ // Arrange
+ const userId = new mongoose.Types.ObjectId();
+ const initialBalance = 17613154.55;
+ await Balance.create({ user: userId, tokenCredits: initialBalance });
+
+ const model = 'claude-3-5-sonnet';
+ const txData = {
+ user: userId,
+ conversationId: 'test-convo',
+ model,
+ context: 'incomplete',
+ balance: { enabled: true },
+ };
+
+ const tokenUsage = {
+ promptTokens: {
+ input: 10,
+ write: 100,
+ read: 5,
+ },
+ completionTokens: 50,
+ };
+
+ // Act
+ const result = await spendStructuredTokens(txData, tokenUsage);
+
+ // Assert:
+ // (Assuming a multiplier for completion of 15 and a cancel rate of 1.15 as noted in the original test.)
+ expect(result.completion.completion).toBeCloseTo(-50 * 15 * 1.15, 0);
+ });
+});
+
+describe('NaN Handling Tests', () => {
+ test('should skip transaction creation when rawAmount is NaN', async () => {
+ // Arrange
+ const userId = new mongoose.Types.ObjectId();
+ const initialBalance = 10000000;
+ await Balance.create({ user: userId, tokenCredits: initialBalance });
+
+ const model = 'gpt-3.5-turbo';
+ const txData = {
+ user: userId,
+ conversationId: 'test-conversation-id',
+ model,
+ context: 'test',
+ endpointTokenConfig: null,
+ rawAmount: NaN,
+ tokenType: 'prompt',
+ balance: { enabled: true },
+ };
+
+ // Act
+ const result = await createTransaction(txData);
+
+ // Assert: No transaction should be created and balance remains unchanged.
+ expect(result).toBeUndefined();
+ const balance = await Balance.findOne({ user: userId });
+ expect(balance.tokenCredits).toBe(initialBalance);
+ });
+});
+
+describe('Transactions Config Tests', () => {
+ test('createTransaction should not save when transactions.enabled is false', async () => {
+ // Arrange
+ const userId = new mongoose.Types.ObjectId();
+ const initialBalance = 10000000;
+ await Balance.create({ user: userId, tokenCredits: initialBalance });
+
+ const model = 'gpt-3.5-turbo';
+ const txData = {
+ user: userId,
+ conversationId: 'test-conversation-id',
+ model,
+ context: 'test',
+ endpointTokenConfig: null,
+ rawAmount: -100,
+ tokenType: 'prompt',
+ transactions: { enabled: false },
+ };
+
+ // Act
+ const result = await createTransaction(txData);
+
+ // Assert: No transaction should be created
+ expect(result).toBeUndefined();
+ const transactions = await Transaction.find({ user: userId });
+ expect(transactions).toHaveLength(0);
+ const balance = await Balance.findOne({ user: userId });
+ expect(balance.tokenCredits).toBe(initialBalance);
+ });
+
+ test('createTransaction should save when transactions.enabled is true', async () => {
+ // Arrange
+ const userId = new mongoose.Types.ObjectId();
+ const initialBalance = 10000000;
+ await Balance.create({ user: userId, tokenCredits: initialBalance });
+
+ const model = 'gpt-3.5-turbo';
+ const txData = {
+ user: userId,
+ conversationId: 'test-conversation-id',
+ model,
+ context: 'test',
+ endpointTokenConfig: null,
+ rawAmount: -100,
+ tokenType: 'prompt',
+ transactions: { enabled: true },
+ balance: { enabled: true },
+ };
+
+ // Act
+ const result = await createTransaction(txData);
+
+ // Assert: Transaction should be created
+ expect(result).toBeDefined();
+ expect(result.balance).toBeLessThan(initialBalance);
+ const transactions = await Transaction.find({ user: userId });
+ expect(transactions).toHaveLength(1);
+ expect(transactions[0].rawAmount).toBe(-100);
+ });
+
+ test('createTransaction should save when balance.enabled is true even if transactions config is missing', async () => {
+ // Arrange
+ const userId = new mongoose.Types.ObjectId();
+ const initialBalance = 10000000;
+ await Balance.create({ user: userId, tokenCredits: initialBalance });
+
+ const model = 'gpt-3.5-turbo';
+ const txData = {
+ user: userId,
+ conversationId: 'test-conversation-id',
+ model,
+ context: 'test',
+ endpointTokenConfig: null,
+ rawAmount: -100,
+ tokenType: 'prompt',
+ balance: { enabled: true },
+ // No transactions config provided
+ };
+
+ // Act
+ const result = await createTransaction(txData);
+
+ // Assert: Transaction should be created (backward compatibility)
+ expect(result).toBeDefined();
+ expect(result.balance).toBeLessThan(initialBalance);
+ const transactions = await Transaction.find({ user: userId });
+ expect(transactions).toHaveLength(1);
+ });
+
+ test('createTransaction should save transaction but not update balance when balance is disabled but transactions enabled', async () => {
+ // Arrange
+ const userId = new mongoose.Types.ObjectId();
+ const initialBalance = 10000000;
+ await Balance.create({ user: userId, tokenCredits: initialBalance });
+
+ const model = 'gpt-3.5-turbo';
+ const txData = {
+ user: userId,
+ conversationId: 'test-conversation-id',
+ model,
+ context: 'test',
+ endpointTokenConfig: null,
+ rawAmount: -100,
+ tokenType: 'prompt',
+ transactions: { enabled: true },
+ balance: { enabled: false },
+ };
+
+ // Act
+ const result = await createTransaction(txData);
+
+ // Assert: Transaction should be created but balance unchanged
+ expect(result).toBeUndefined();
+ const transactions = await Transaction.find({ user: userId });
+ expect(transactions).toHaveLength(1);
+ expect(transactions[0].rawAmount).toBe(-100);
+ const balance = await Balance.findOne({ user: userId });
+ expect(balance.tokenCredits).toBe(initialBalance);
+ });
+
+ test('createStructuredTransaction should not save when transactions.enabled is false', async () => {
+ // Arrange
+ const userId = new mongoose.Types.ObjectId();
+ const initialBalance = 10000000;
+ await Balance.create({ user: userId, tokenCredits: initialBalance });
+
+ const model = 'claude-3-5-sonnet';
+ const txData = {
+ user: userId,
+ conversationId: 'test-conversation-id',
+ model,
+ context: 'message',
+ tokenType: 'prompt',
+ inputTokens: -10,
+ writeTokens: -100,
+ readTokens: -5,
+ transactions: { enabled: false },
+ };
+
+ // Act
+ const result = await createStructuredTransaction(txData);
+
+ // Assert: No transaction should be created
+ expect(result).toBeUndefined();
+ const transactions = await Transaction.find({ user: userId });
+ expect(transactions).toHaveLength(0);
+ const balance = await Balance.findOne({ user: userId });
+ expect(balance.tokenCredits).toBe(initialBalance);
+ });
+
+ test('createStructuredTransaction should save transaction but not update balance when balance is disabled but transactions enabled', async () => {
+ // Arrange
+ const userId = new mongoose.Types.ObjectId();
+ const initialBalance = 10000000;
+ await Balance.create({ user: userId, tokenCredits: initialBalance });
+
+ const model = 'claude-3-5-sonnet';
+ const txData = {
+ user: userId,
+ conversationId: 'test-conversation-id',
+ model,
+ context: 'message',
+ tokenType: 'prompt',
+ inputTokens: -10,
+ writeTokens: -100,
+ readTokens: -5,
+ transactions: { enabled: true },
+ balance: { enabled: false },
+ };
+
+ // Act
+ const result = await createStructuredTransaction(txData);
+
+ // Assert: Transaction should be created but balance unchanged
+ expect(result).toBeUndefined();
+ const transactions = await Transaction.find({ user: userId });
+ expect(transactions).toHaveLength(1);
+ expect(transactions[0].inputTokens).toBe(-10);
+ expect(transactions[0].writeTokens).toBe(-100);
+ expect(transactions[0].readTokens).toBe(-5);
+ const balance = await Balance.findOne({ user: userId });
+ expect(balance.tokenCredits).toBe(initialBalance);
+ });
+});
diff --git a/api/models/balanceMethods.js b/api/models/balanceMethods.js
new file mode 100644
index 0000000000..e614872eac
--- /dev/null
+++ b/api/models/balanceMethods.js
@@ -0,0 +1,156 @@
+const { logger } = require('@librechat/data-schemas');
+const { ViolationTypes } = require('librechat-data-provider');
+const { createAutoRefillTransaction } = require('./Transaction');
+const { logViolation } = require('~/cache');
+const { getMultiplier } = require('./tx');
+const { Balance } = require('~/db/models');
+
+function isInvalidDate(date) {
+ return isNaN(date);
+}
+
+/**
+ * Simple check method that calculates token cost and returns balance info.
+ * The auto-refill logic has been moved to balanceMethods.js to prevent circular dependencies.
+ */
+const checkBalanceRecord = async function ({
+ user,
+ model,
+ endpoint,
+ valueKey,
+ tokenType,
+ amount,
+ endpointTokenConfig,
+}) {
+ const multiplier = getMultiplier({ valueKey, tokenType, model, endpoint, endpointTokenConfig });
+ const tokenCost = amount * multiplier;
+
+ // Retrieve the balance record
+ let record = await Balance.findOne({ user }).lean();
+ if (!record) {
+ logger.debug('[Balance.check] No balance record found for user', { user });
+ return {
+ canSpend: false,
+ balance: 0,
+ tokenCost,
+ };
+ }
+ let balance = record.tokenCredits;
+
+ logger.debug('[Balance.check] Initial state', {
+ user,
+ model,
+ endpoint,
+ valueKey,
+ tokenType,
+ amount,
+ balance,
+ multiplier,
+ endpointTokenConfig: !!endpointTokenConfig,
+ });
+
+ // Only perform auto-refill if spending would bring the balance to 0 or below
+ if (balance - tokenCost <= 0 && record.autoRefillEnabled && record.refillAmount > 0) {
+ const lastRefillDate = new Date(record.lastRefill);
+ const now = new Date();
+ if (
+ isInvalidDate(lastRefillDate) ||
+ now >=
+ addIntervalToDate(lastRefillDate, record.refillIntervalValue, record.refillIntervalUnit)
+ ) {
+ try {
+ /** @type {{ rate: number, user: string, balance: number, transaction: import('@librechat/data-schemas').ITransaction}} */
+ const result = await createAutoRefillTransaction({
+ user: user,
+ tokenType: 'credits',
+ context: 'autoRefill',
+ rawAmount: record.refillAmount,
+ });
+ balance = result.balance;
+ } catch (error) {
+ logger.error('[Balance.check] Failed to record transaction for auto-refill', error);
+ }
+ }
+ }
+
+ logger.debug('[Balance.check] Token cost', { tokenCost });
+ return { canSpend: balance >= tokenCost, balance, tokenCost };
+};
+
+/**
+ * Adds a time interval to a given date.
+ * @param {Date} date - The starting date.
+ * @param {number} value - The numeric value of the interval.
+ * @param {'seconds'|'minutes'|'hours'|'days'|'weeks'|'months'} unit - The unit of time.
+ * @returns {Date} A new Date representing the starting date plus the interval.
+ */
+const addIntervalToDate = (date, value, unit) => {
+ const result = new Date(date);
+ switch (unit) {
+ case 'seconds':
+ result.setSeconds(result.getSeconds() + value);
+ break;
+ case 'minutes':
+ result.setMinutes(result.getMinutes() + value);
+ break;
+ case 'hours':
+ result.setHours(result.getHours() + value);
+ break;
+ case 'days':
+ result.setDate(result.getDate() + value);
+ break;
+ case 'weeks':
+ result.setDate(result.getDate() + value * 7);
+ break;
+ case 'months':
+ result.setMonth(result.getMonth() + value);
+ break;
+ default:
+ break;
+ }
+ return result;
+};
+
+/**
+ * Checks the balance for a user and determines if they can spend a certain amount.
+ * If the user cannot spend the amount, it logs a violation and denies the request.
+ *
+ * @async
+ * @function
+ * @param {Object} params - The function parameters.
+ * @param {ServerRequest} params.req - The Express request object.
+ * @param {Express.Response} params.res - The Express response object.
+ * @param {Object} params.txData - The transaction data.
+ * @param {string} params.txData.user - The user ID or identifier.
+ * @param {('prompt' | 'completion')} params.txData.tokenType - The type of token.
+ * @param {number} params.txData.amount - The amount of tokens.
+ * @param {string} params.txData.model - The model name or identifier.
+ * @param {string} [params.txData.endpointTokenConfig] - The token configuration for the endpoint.
+ * @returns {Promise} Throws error if the user cannot spend the amount.
+ * @throws {Error} Throws an error if there's an issue with the balance check.
+ */
+const checkBalance = async ({ req, res, txData }) => {
+ const { canSpend, balance, tokenCost } = await checkBalanceRecord(txData);
+ if (canSpend) {
+ return true;
+ }
+
+ const type = ViolationTypes.TOKEN_BALANCE;
+ const errorMessage = {
+ type,
+ balance,
+ tokenCost,
+ promptTokens: txData.amount,
+ };
+
+ if (txData.generations && txData.generations.length > 0) {
+ errorMessage.generations = txData.generations;
+ }
+
+ await logViolation(req, res, type, errorMessage, 0);
+ throw new Error(JSON.stringify(errorMessage));
+};
+
+module.exports = {
+ checkBalance,
+};
diff --git a/api/models/convoStructure.spec.js b/api/models/convoStructure.spec.js
new file mode 100644
index 0000000000..440f21cb06
--- /dev/null
+++ b/api/models/convoStructure.spec.js
@@ -0,0 +1,275 @@
+const mongoose = require('mongoose');
+const { buildTree } = require('librechat-data-provider');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { getMessages, bulkSaveMessages } = require('./Message');
+const { Message } = require('~/db/models');
+
+let mongod;
+beforeAll(async () => {
+ mongod = await MongoMemoryServer.create();
+ const uri = mongod.getUri();
+ await mongoose.connect(uri);
+});
+
+afterAll(async () => {
+ await mongoose.disconnect();
+ await mongod.stop();
+});
+
+beforeEach(async () => {
+ await Message.deleteMany({});
+});
+
+describe('Conversation Structure Tests', () => {
+ test('Conversation folding/corrupting with inconsistent timestamps', async () => {
+ const userId = 'testUser';
+ const conversationId = 'testConversation';
+
+ // Create messages with inconsistent timestamps
+ const messages = [
+ {
+ messageId: 'message0',
+ parentMessageId: null,
+ text: 'Message 0',
+ createdAt: new Date('2023-01-01T00:00:00Z'),
+ },
+ {
+ messageId: 'message1',
+ parentMessageId: 'message0',
+ text: 'Message 1',
+ createdAt: new Date('2023-01-01T00:02:00Z'),
+ },
+ {
+ messageId: 'message2',
+ parentMessageId: 'message1',
+ text: 'Message 2',
+ createdAt: new Date('2023-01-01T00:01:00Z'),
+ }, // Note: Earlier than its parent
+ {
+ messageId: 'message3',
+ parentMessageId: 'message1',
+ text: 'Message 3',
+ createdAt: new Date('2023-01-01T00:03:00Z'),
+ },
+ {
+ messageId: 'message4',
+ parentMessageId: 'message2',
+ text: 'Message 4',
+ createdAt: new Date('2023-01-01T00:04:00Z'),
+ },
+ ];
+
+ // Add common properties to all messages
+ messages.forEach((msg) => {
+ msg.conversationId = conversationId;
+ msg.user = userId;
+ msg.isCreatedByUser = false;
+ msg.error = false;
+ msg.unfinished = false;
+ });
+
+ // Save messages with overrideTimestamp omitted (default is false)
+ await bulkSaveMessages(messages, true);
+
+ // Retrieve messages (this will sort by createdAt)
+ const retrievedMessages = await getMessages({ conversationId, user: userId });
+
+ // Build tree
+ const tree = buildTree({ messages: retrievedMessages });
+
+ // Check if the tree is incorrect (folded/corrupted)
+ expect(tree.length).toBeGreaterThan(1); // Should have multiple root messages, indicating corruption
+ });
+
+ test('Fix: Conversation structure maintained with more than 16 messages', async () => {
+ const userId = 'testUser';
+ const conversationId = 'testConversation';
+
+ // Create more than 16 messages
+ const messages = Array.from({ length: 20 }, (_, i) => ({
+ messageId: `message${i}`,
+ parentMessageId: i === 0 ? null : `message${i - 1}`,
+ conversationId,
+ user: userId,
+ text: `Message ${i}`,
+ createdAt: new Date(Date.now() + (i % 2 === 0 ? i * 500000 : -i * 500000)),
+ }));
+
+ // Save messages with new timestamps being generated (message objects ignored)
+ await bulkSaveMessages(messages);
+
+ // Retrieve messages (this will sort by createdAt, but it shouldn't matter now)
+ const retrievedMessages = await getMessages({ conversationId, user: userId });
+
+ // Build tree
+ const tree = buildTree({ messages: retrievedMessages });
+
+ // Check if the tree is correct
+ expect(tree.length).toBe(1); // Should have only one root message
+ let currentNode = tree[0];
+ for (let i = 1; i < 20; i++) {
+ expect(currentNode.children.length).toBe(1);
+ currentNode = currentNode.children[0];
+ expect(currentNode.text).toBe(`Message ${i}`);
+ }
+ expect(currentNode.children.length).toBe(0); // Last message should have no children
+ });
+
+ test('Simulate MongoDB ordering issue with more than 16 messages and close timestamps', async () => {
+ const userId = 'testUser';
+ const conversationId = 'testConversation';
+
+ // Create more than 16 messages with very close timestamps
+ const messages = Array.from({ length: 20 }, (_, i) => ({
+ messageId: `message${i}`,
+ parentMessageId: i === 0 ? null : `message${i - 1}`,
+ conversationId,
+ user: userId,
+ text: `Message ${i}`,
+ createdAt: new Date(Date.now() + (i % 2 === 0 ? i * 1 : -i * 1)),
+ }));
+
+ // Add common properties to all messages
+ messages.forEach((msg) => {
+ msg.isCreatedByUser = false;
+ msg.error = false;
+ msg.unfinished = false;
+ });
+
+ await bulkSaveMessages(messages, true);
+ const retrievedMessages = await getMessages({ conversationId, user: userId });
+ const tree = buildTree({ messages: retrievedMessages });
+ expect(tree.length).toBeGreaterThan(1);
+ });
+
+ test('Fix: Preserve order with more than 16 messages by maintaining original timestamps', async () => {
+ const userId = 'testUser';
+ const conversationId = 'testConversation';
+
+ // Create more than 16 messages with distinct timestamps
+ const messages = Array.from({ length: 20 }, (_, i) => ({
+ messageId: `message${i}`,
+ parentMessageId: i === 0 ? null : `message${i - 1}`,
+ conversationId,
+ user: userId,
+ text: `Message ${i}`,
+ createdAt: new Date(Date.now() + i * 1000), // Ensure each message has a distinct timestamp
+ }));
+
+ // Add common properties to all messages
+ messages.forEach((msg) => {
+ msg.isCreatedByUser = false;
+ msg.error = false;
+ msg.unfinished = false;
+ });
+
+ // Save messages with overriding timestamps (preserve original timestamps)
+ await bulkSaveMessages(messages, true);
+
+ // Retrieve messages (this will sort by createdAt)
+ const retrievedMessages = await getMessages({ conversationId, user: userId });
+
+ // Build tree
+ const tree = buildTree({ messages: retrievedMessages });
+
+ // Check if the tree is correct
+ expect(tree.length).toBe(1); // Should have only one root message
+ let currentNode = tree[0];
+ for (let i = 1; i < 20; i++) {
+ expect(currentNode.children.length).toBe(1);
+ currentNode = currentNode.children[0];
+ expect(currentNode.text).toBe(`Message ${i}`);
+ }
+ expect(currentNode.children.length).toBe(0); // Last message should have no children
+ });
+
+ test('Random order dates between parent and children messages', async () => {
+ const userId = 'testUser';
+ const conversationId = 'testConversation';
+
+ // Create messages with deliberately out-of-order timestamps but sequential creation
+ const messages = [
+ {
+ messageId: 'parent',
+ parentMessageId: null,
+ text: 'Parent Message',
+ createdAt: new Date('2023-01-01T00:00:00Z'), // Make parent earliest
+ },
+ {
+ messageId: 'child1',
+ parentMessageId: 'parent',
+ text: 'Child Message 1',
+ createdAt: new Date('2023-01-01T00:01:00Z'),
+ },
+ {
+ messageId: 'child2',
+ parentMessageId: 'parent',
+ text: 'Child Message 2',
+ createdAt: new Date('2023-01-01T00:02:00Z'),
+ },
+ {
+ messageId: 'grandchild1',
+ parentMessageId: 'child1',
+ text: 'Grandchild Message 1',
+ createdAt: new Date('2023-01-01T00:03:00Z'),
+ },
+ ];
+
+ // Add common properties to all messages
+ messages.forEach((msg) => {
+ msg.conversationId = conversationId;
+ msg.user = userId;
+ msg.isCreatedByUser = false;
+ msg.error = false;
+ msg.unfinished = false;
+ });
+
+ // Save messages with overrideTimestamp set to true
+ await bulkSaveMessages(messages, true);
+
+ // Retrieve messages
+ const retrievedMessages = await getMessages({ conversationId, user: userId });
+
+ // Debug log to see what's being returned
+ console.log(
+ 'Retrieved Messages:',
+ retrievedMessages.map((msg) => ({
+ messageId: msg.messageId,
+ parentMessageId: msg.parentMessageId,
+ createdAt: msg.createdAt,
+ })),
+ );
+
+ // Build tree
+ const tree = buildTree({ messages: retrievedMessages });
+
+ // Debug log to see the tree structure
+ console.log(
+ 'Tree structure:',
+ tree.map((root) => ({
+ messageId: root.messageId,
+ children: root.children.map((child) => ({
+ messageId: child.messageId,
+ children: child.children.map((grandchild) => ({
+ messageId: grandchild.messageId,
+ })),
+ })),
+ })),
+ );
+
+ // Verify the structure before making assertions
+ expect(retrievedMessages.length).toBe(4); // Should have all 4 messages
+
+ // Check if messages are properly linked
+ const parentMsg = retrievedMessages.find((msg) => msg.messageId === 'parent');
+ expect(parentMsg.parentMessageId).toBeNull(); // Parent should have null parentMessageId
+
+ const childMsg1 = retrievedMessages.find((msg) => msg.messageId === 'child1');
+ expect(childMsg1.parentMessageId).toBe('parent');
+
+ // Then check tree structure
+ expect(tree.length).toBe(1); // Should have only one root message
+ expect(tree[0].messageId).toBe('parent');
+ expect(tree[0].children.length).toBe(2); // Should have two children
+ });
+});
diff --git a/api/models/dbConnect.js b/api/models/dbConnect.js
deleted file mode 100644
index f9a8455442..0000000000
--- a/api/models/dbConnect.js
+++ /dev/null
@@ -1,44 +0,0 @@
-require('dotenv').config();
-const mongoose = require('mongoose');
-const MONGO_URI = process.env.MONGO_URI;
-
-if (!MONGO_URI) {
- throw new Error('Please define the MONGO_URI environment variable inside .env.local');
-}
-
-/**
- * Global is used here to maintain a cached connection across hot reloads
- * in development. This prevents connections growing exponentially
- * during API Route usage.
- */
-let cached = global.mongoose;
-
-if (!cached) {
- cached = global.mongoose = { conn: null, promise: null };
-}
-
-async function dbConnect() {
- if (cached.conn) {
- return cached.conn;
- }
-
- if (!cached.promise) {
- const opts = {
- useNewUrlParser: true,
- useUnifiedTopology: true,
- bufferCommands: false
- // bufferMaxEntries: 0,
- // useFindAndModify: true,
- // useCreateIndex: true
- };
-
- mongoose.set('strictQuery', true);
- cached.promise = mongoose.connect(MONGO_URI, opts).then((mongoose) => {
- return mongoose;
- });
- }
- cached.conn = await cached.promise;
- return cached.conn;
-}
-
-module.exports = dbConnect;
diff --git a/api/models/index.js b/api/models/index.js
index 8af5a1aa9c..d0b10be079 100644
--- a/api/models/index.js
+++ b/api/models/index.js
@@ -1,15 +1,48 @@
-const { saveMessage, deleteMessages } = require('./Message');
-const { getCustomGpts, updateCustomGpt, updateByLabel, deleteCustomGpts } = require('./CustomGpt');
-const { getConvoTitle, getConvo, saveConvo } = require('./Conversation');
+const mongoose = require('mongoose');
+const { createMethods } = require('@librechat/data-schemas');
+const methods = createMethods(mongoose);
+const { comparePassword } = require('./userMethods');
+const {
+ getMessage,
+ getMessages,
+ saveMessage,
+ recordMessage,
+ updateMessage,
+ deleteMessagesSince,
+ deleteMessages,
+} = require('./Message');
+const { getConvoTitle, getConvo, saveConvo, deleteConvos } = require('./Conversation');
+const { getPreset, getPresets, savePreset, deletePresets } = require('./Preset');
+const { File } = require('~/db/models');
+
+const seedDatabase = async () => {
+ await methods.initializeRoles();
+ await methods.seedDefaultRoles();
+ await methods.ensureDefaultCategories();
+};
module.exports = {
+ ...methods,
+ seedDatabase,
+ comparePassword,
+
+ getMessage,
+ getMessages,
saveMessage,
+ recordMessage,
+ updateMessage,
+ deleteMessagesSince,
deleteMessages,
+
getConvoTitle,
getConvo,
saveConvo,
- getCustomGpts,
- updateCustomGpt,
- updateByLabel,
- deleteCustomGpts
+ deleteConvos,
+
+ getPreset,
+ getPresets,
+ savePreset,
+ deletePresets,
+
+ Files: File,
};
diff --git a/api/models/interface.js b/api/models/interface.js
new file mode 100644
index 0000000000..a79a8e747f
--- /dev/null
+++ b/api/models/interface.js
@@ -0,0 +1,24 @@
+const { logger } = require('@librechat/data-schemas');
+const { updateInterfacePermissions: updateInterfacePerms } = require('@librechat/api');
+const { getRoleByName, updateAccessPermissions } = require('./Role');
+
+/**
+ * Update interface permissions based on app configuration.
+ * Must be done independently from loading the app config.
+ * @param {AppConfig} appConfig
+ */
+async function updateInterfacePermissions(appConfig) {
+ try {
+ await updateInterfacePerms({
+ appConfig,
+ getRoleByName,
+ updateAccessPermissions,
+ });
+ } catch (error) {
+ logger.error('Error updating interface permissions:', error);
+ }
+}
+
+module.exports = {
+ updateInterfacePermissions,
+};
diff --git a/api/models/inviteUser.js b/api/models/inviteUser.js
new file mode 100644
index 0000000000..eda8394225
--- /dev/null
+++ b/api/models/inviteUser.js
@@ -0,0 +1,68 @@
+const mongoose = require('mongoose');
+const { logger, hashToken, getRandomValues } = require('@librechat/data-schemas');
+const { createToken, findToken } = require('~/models');
+
+/**
+ * @module inviteUser
+ * @description This module provides functions to create and get user invites
+ */
+
+/**
+ * @function createInvite
+ * @description This function creates a new user invite
+ * @param {string} email - The email of the user to invite
+ * @returns {Promise} A promise that resolves to the saved invite document
+ * @throws {Error} If there is an error creating the invite
+ */
+const createInvite = async (email) => {
+ try {
+ const token = await getRandomValues(32);
+ const hash = await hashToken(token);
+ const encodedToken = encodeURIComponent(token);
+
+ const fakeUserId = new mongoose.Types.ObjectId();
+
+ await createToken({
+ userId: fakeUserId,
+ email,
+ token: hash,
+ createdAt: Date.now(),
+ expiresIn: 604800,
+ });
+
+ return encodedToken;
+ } catch (error) {
+ logger.error('[createInvite] Error creating invite', error);
+ return { message: 'Error creating invite' };
+ }
+};
+
+/**
+ * @function getInvite
+ * @description This function retrieves a user invite
+ * @param {string} encodedToken - The token of the invite to retrieve
+ * @param {string} email - The email of the user to validate
+ * @returns {Promise} A promise that resolves to the retrieved invite document
+ * @throws {Error} If there is an error retrieving the invite, if the invite does not exist, or if the email does not match
+ */
+const getInvite = async (encodedToken, email) => {
+ try {
+ const token = decodeURIComponent(encodedToken);
+ const hash = await hashToken(token);
+ const invite = await findToken({ token: hash, email });
+
+ if (!invite) {
+ throw new Error('Invite not found or email does not match');
+ }
+
+ return invite;
+ } catch (error) {
+ logger.error('[getInvite] Error getting invite:', error);
+ return { error: true, message: error.message };
+ }
+};
+
+module.exports = {
+ createInvite,
+ getInvite,
+};
diff --git a/api/models/spendTokens.js b/api/models/spendTokens.js
new file mode 100644
index 0000000000..cfd983f6bb
--- /dev/null
+++ b/api/models/spendTokens.js
@@ -0,0 +1,127 @@
+const { logger } = require('@librechat/data-schemas');
+const { createTransaction, createStructuredTransaction } = require('./Transaction');
+/**
+ * Creates up to two transactions to record the spending of tokens.
+ *
+ * @function
+ * @async
+ * @param {txData} txData - Transaction data.
+ * @param {Object} tokenUsage - The number of tokens used.
+ * @param {Number} tokenUsage.promptTokens - The number of prompt tokens used.
+ * @param {Number} tokenUsage.completionTokens - The number of completion tokens used.
+ * @returns {Promise} - Returns nothing.
+ * @throws {Error} - Throws an error if there's an issue creating the transactions.
+ */
+const spendTokens = async (txData, tokenUsage) => {
+ const { promptTokens, completionTokens } = tokenUsage;
+ logger.debug(
+ `[spendTokens] conversationId: ${txData.conversationId}${
+ txData?.context ? ` | Context: ${txData?.context}` : ''
+ } | Token usage: `,
+ {
+ promptTokens,
+ completionTokens,
+ },
+ );
+ let prompt, completion;
+ try {
+ if (promptTokens !== undefined) {
+ prompt = await createTransaction({
+ ...txData,
+ tokenType: 'prompt',
+ rawAmount: promptTokens === 0 ? 0 : -Math.max(promptTokens, 0),
+ });
+ }
+
+ if (completionTokens !== undefined) {
+ completion = await createTransaction({
+ ...txData,
+ tokenType: 'completion',
+ rawAmount: completionTokens === 0 ? 0 : -Math.max(completionTokens, 0),
+ });
+ }
+
+ if (prompt || completion) {
+ logger.debug('[spendTokens] Transaction data record against balance:', {
+ user: txData.user,
+ prompt: prompt?.prompt,
+ promptRate: prompt?.rate,
+ completion: completion?.completion,
+ completionRate: completion?.rate,
+ balance: completion?.balance ?? prompt?.balance,
+ });
+ } else {
+ logger.debug('[spendTokens] No transactions incurred against balance');
+ }
+ } catch (err) {
+ logger.error('[spendTokens]', err);
+ }
+};
+
+/**
+ * Creates transactions to record the spending of structured tokens.
+ *
+ * @function
+ * @async
+ * @param {txData} txData - Transaction data.
+ * @param {Object} tokenUsage - The number of tokens used.
+ * @param {Object} tokenUsage.promptTokens - The number of prompt tokens used.
+ * @param {Number} tokenUsage.promptTokens.input - The number of input tokens.
+ * @param {Number} tokenUsage.promptTokens.write - The number of write tokens.
+ * @param {Number} tokenUsage.promptTokens.read - The number of read tokens.
+ * @param {Number} tokenUsage.completionTokens - The number of completion tokens used.
+ * @returns {Promise} - Returns nothing.
+ * @throws {Error} - Throws an error if there's an issue creating the transactions.
+ */
+const spendStructuredTokens = async (txData, tokenUsage) => {
+ const { promptTokens, completionTokens } = tokenUsage;
+ logger.debug(
+ `[spendStructuredTokens] conversationId: ${txData.conversationId}${
+ txData?.context ? ` | Context: ${txData?.context}` : ''
+ } | Token usage: `,
+ {
+ promptTokens,
+ completionTokens,
+ },
+ );
+ let prompt, completion;
+ try {
+ if (promptTokens) {
+ const { input = 0, write = 0, read = 0 } = promptTokens;
+ prompt = await createStructuredTransaction({
+ ...txData,
+ tokenType: 'prompt',
+ inputTokens: -input,
+ writeTokens: -write,
+ readTokens: -read,
+ });
+ }
+
+ if (completionTokens) {
+ completion = await createTransaction({
+ ...txData,
+ tokenType: 'completion',
+ rawAmount: -completionTokens,
+ });
+ }
+
+ if (prompt || completion) {
+ logger.debug('[spendStructuredTokens] Transaction data record against balance:', {
+ user: txData.user,
+ prompt: prompt?.prompt,
+ promptRate: prompt?.rate,
+ completion: completion?.completion,
+ completionRate: completion?.rate,
+ balance: completion?.balance ?? prompt?.balance,
+ });
+ } else {
+ logger.debug('[spendStructuredTokens] No transactions incurred against balance');
+ }
+ } catch (err) {
+ logger.error('[spendStructuredTokens]', err);
+ }
+
+ return { prompt, completion };
+};
+
+module.exports = { spendTokens, spendStructuredTokens };
diff --git a/api/models/spendTokens.spec.js b/api/models/spendTokens.spec.js
new file mode 100644
index 0000000000..eee6572736
--- /dev/null
+++ b/api/models/spendTokens.spec.js
@@ -0,0 +1,737 @@
+const mongoose = require('mongoose');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { spendTokens, spendStructuredTokens } = require('./spendTokens');
+const { createTransaction, createAutoRefillTransaction } = require('./Transaction');
+
+require('~/db/models');
+
+jest.mock('~/config', () => ({
+ logger: {
+ debug: jest.fn(),
+ error: jest.fn(),
+ },
+}));
+
+describe('spendTokens', () => {
+ let mongoServer;
+ let userId;
+ let Transaction;
+ let Balance;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ await mongoose.connect(mongoServer.getUri());
+
+ Transaction = mongoose.model('Transaction');
+ Balance = mongoose.model('Balance');
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ // Clear collections before each test
+ await Transaction.deleteMany({});
+ await Balance.deleteMany({});
+
+ // Create a new user ID for each test
+ userId = new mongoose.Types.ObjectId();
+
+ // Balance config is now passed directly in txData
+ });
+
+ it('should create transactions for both prompt and completion tokens', async () => {
+ // Create a balance for the user
+ await Balance.create({
+ user: userId,
+ tokenCredits: 10000,
+ });
+
+ const txData = {
+ user: userId,
+ conversationId: 'test-convo',
+ model: 'gpt-3.5-turbo',
+ context: 'test',
+ balance: { enabled: true },
+ };
+ const tokenUsage = {
+ promptTokens: 100,
+ completionTokens: 50,
+ };
+
+ await spendTokens(txData, tokenUsage);
+
+ // Verify transactions were created
+ const transactions = await Transaction.find({ user: userId }).sort({ tokenType: 1 });
+ expect(transactions).toHaveLength(2);
+
+ // Check completion transaction
+ expect(transactions[0].tokenType).toBe('completion');
+ expect(transactions[0].rawAmount).toBe(-50);
+
+ // Check prompt transaction
+ expect(transactions[1].tokenType).toBe('prompt');
+ expect(transactions[1].rawAmount).toBe(-100);
+
+ // Verify balance was updated
+ const balance = await Balance.findOne({ user: userId });
+ expect(balance).toBeDefined();
+ expect(balance.tokenCredits).toBeLessThan(10000); // Balance should be reduced
+ });
+
+ it('should handle zero completion tokens', async () => {
+ // Create a balance for the user
+ await Balance.create({
+ user: userId,
+ tokenCredits: 10000,
+ });
+
+ const txData = {
+ user: userId,
+ conversationId: 'test-convo',
+ model: 'gpt-3.5-turbo',
+ context: 'test',
+ balance: { enabled: true },
+ };
+ const tokenUsage = {
+ promptTokens: 100,
+ completionTokens: 0,
+ };
+
+ await spendTokens(txData, tokenUsage);
+
+ // Verify transactions were created
+ const transactions = await Transaction.find({ user: userId }).sort({ tokenType: 1 });
+ expect(transactions).toHaveLength(2);
+
+ // Check completion transaction
+ expect(transactions[0].tokenType).toBe('completion');
+ // In JavaScript -0 and 0 are different but functionally equivalent
+ // Use Math.abs to handle both 0 and -0
+ expect(Math.abs(transactions[0].rawAmount)).toBe(0);
+
+ // Check prompt transaction
+ expect(transactions[1].tokenType).toBe('prompt');
+ expect(transactions[1].rawAmount).toBe(-100);
+ });
+
+ it('should handle undefined token counts', async () => {
+ const txData = {
+ user: userId,
+ conversationId: 'test-convo',
+ model: 'gpt-3.5-turbo',
+ context: 'test',
+ balance: { enabled: true },
+ };
+ const tokenUsage = {};
+
+ await spendTokens(txData, tokenUsage);
+
+ // Verify no transactions were created
+ const transactions = await Transaction.find({ user: userId });
+ expect(transactions).toHaveLength(0);
+ });
+
+ it('should not update balance when the balance feature is disabled', async () => {
+ // Balance is now passed directly in txData
+ // Create a balance for the user
+ await Balance.create({
+ user: userId,
+ tokenCredits: 10000,
+ });
+
+ const txData = {
+ user: userId,
+ conversationId: 'test-convo',
+ model: 'gpt-3.5-turbo',
+ context: 'test',
+ balance: { enabled: false },
+ };
+ const tokenUsage = {
+ promptTokens: 100,
+ completionTokens: 50,
+ };
+
+ await spendTokens(txData, tokenUsage);
+
+ // Verify transactions were created
+ const transactions = await Transaction.find({ user: userId });
+ expect(transactions).toHaveLength(2);
+
+ // Verify balance was not updated (should still be 10000)
+ const balance = await Balance.findOne({ user: userId });
+ expect(balance.tokenCredits).toBe(10000);
+ });
+
+ it('should not allow balance to go below zero when spending tokens', async () => {
+ // Create a balance with a low amount
+ await Balance.create({
+ user: userId,
+ tokenCredits: 5000,
+ });
+
+ const txData = {
+ user: userId,
+ conversationId: 'test-convo',
+ model: 'gpt-4', // Using a more expensive model
+ context: 'test',
+ balance: { enabled: true },
+ };
+
+ // Spending more tokens than the user has balance for
+ const tokenUsage = {
+ promptTokens: 1000,
+ completionTokens: 500,
+ };
+
+ await spendTokens(txData, tokenUsage);
+
+ // Verify transactions were created
+ const transactions = await Transaction.find({ user: userId }).sort({ tokenType: 1 });
+ expect(transactions).toHaveLength(2);
+
+ // Verify balance was reduced to exactly 0, not negative
+ const balance = await Balance.findOne({ user: userId });
+ expect(balance).toBeDefined();
+ expect(balance.tokenCredits).toBe(0);
+
+ // Check that the transaction records show the adjusted values
+ const transactionResults = await Promise.all(
+ transactions.map((t) =>
+ createTransaction({
+ ...txData,
+ tokenType: t.tokenType,
+ rawAmount: t.rawAmount,
+ }),
+ ),
+ );
+
+ // The second transaction should have an adjusted value since balance is already 0
+ expect(transactionResults[1]).toEqual(
+ expect.objectContaining({
+ balance: 0,
+ }),
+ );
+ });
+
+ it('should handle multiple transactions in sequence with low balance and not increase balance', async () => {
+ // This test is specifically checking for the issue reported in production
+ // where the balance increases after a transaction when it should remain at 0
+ // Create a balance with a very low amount
+ await Balance.create({
+ user: userId,
+ tokenCredits: 100,
+ });
+
+ // First transaction - should reduce balance to 0
+ const txData1 = {
+ user: userId,
+ conversationId: 'test-convo-1',
+ model: 'gpt-4',
+ context: 'test',
+ balance: { enabled: true },
+ };
+
+ const tokenUsage1 = {
+ promptTokens: 100,
+ completionTokens: 50,
+ };
+
+ await spendTokens(txData1, tokenUsage1);
+
+ // Check balance after first transaction
+ let balance = await Balance.findOne({ user: userId });
+ expect(balance.tokenCredits).toBe(0);
+
+ // Second transaction - should keep balance at 0, not make it negative or increase it
+ const txData2 = {
+ user: userId,
+ conversationId: 'test-convo-2',
+ model: 'gpt-4',
+ context: 'test',
+ balance: { enabled: true },
+ };
+
+ const tokenUsage2 = {
+ promptTokens: 200,
+ completionTokens: 100,
+ };
+
+ await spendTokens(txData2, tokenUsage2);
+
+ // Check balance after second transaction - should still be 0
+ balance = await Balance.findOne({ user: userId });
+ expect(balance.tokenCredits).toBe(0);
+
+ // Verify all transactions were created
+ const transactions = await Transaction.find({ user: userId });
+ expect(transactions).toHaveLength(4); // 2 transactions (prompt+completion) for each call
+
+ // Let's examine the actual transaction records to see what's happening
+ const transactionDetails = await Transaction.find({ user: userId }).sort({ createdAt: 1 });
+
+ // Log the transaction details for debugging
+ console.log('Transaction details:');
+ transactionDetails.forEach((tx, i) => {
+ console.log(`Transaction ${i + 1}:`, {
+ tokenType: tx.tokenType,
+ rawAmount: tx.rawAmount,
+ tokenValue: tx.tokenValue,
+ model: tx.model,
+ });
+ });
+
+ // Check the return values from Transaction.create directly
+ // This is to verify that the incrementValue is not becoming positive
+ const directResult = await createTransaction({
+ user: userId,
+ conversationId: 'test-convo-3',
+ model: 'gpt-4',
+ tokenType: 'completion',
+ rawAmount: -100,
+ context: 'test',
+ balance: { enabled: true },
+ });
+
+ console.log('Direct Transaction.create result:', directResult);
+
+ // The completion value should never be positive
+ expect(directResult.completion).not.toBeGreaterThan(0);
+ });
+
+ it('should ensure tokenValue is always negative for spending tokens', async () => {
+ // Create a balance for the user
+ await Balance.create({
+ user: userId,
+ tokenCredits: 10000,
+ });
+
+ // Test with various models to check multiplier calculations
+ const models = ['gpt-3.5-turbo', 'gpt-4', 'claude-3-5-sonnet'];
+
+ for (const model of models) {
+ const txData = {
+ user: userId,
+ conversationId: `test-convo-${model}`,
+ model,
+ context: 'test',
+ balance: { enabled: true },
+ };
+
+ const tokenUsage = {
+ promptTokens: 100,
+ completionTokens: 50,
+ };
+
+ await spendTokens(txData, tokenUsage);
+
+ // Get the transactions for this model
+ const transactions = await Transaction.find({
+ user: userId,
+ model,
+ });
+
+ // Verify tokenValue is negative for all transactions
+ transactions.forEach((tx) => {
+ console.log(`Model ${model}, Type ${tx.tokenType}: tokenValue = ${tx.tokenValue}`);
+ expect(tx.tokenValue).toBeLessThan(0);
+ });
+ }
+ });
+
+ it('should handle structured transactions in sequence with low balance', async () => {
+ // Create a balance with a very low amount
+ await Balance.create({
+ user: userId,
+ tokenCredits: 100,
+ });
+
+ // First transaction - should reduce balance to 0
+ const txData1 = {
+ user: userId,
+ conversationId: 'test-convo-1',
+ model: 'claude-3-5-sonnet',
+ context: 'test',
+ balance: { enabled: true },
+ };
+
+ const tokenUsage1 = {
+ promptTokens: {
+ input: 10,
+ write: 100,
+ read: 5,
+ },
+ completionTokens: 50,
+ };
+
+ await spendStructuredTokens(txData1, tokenUsage1);
+
+ // Check balance after first transaction
+ let balance = await Balance.findOne({ user: userId });
+ expect(balance.tokenCredits).toBe(0);
+
+ // Second transaction - should keep balance at 0, not make it negative or increase it
+ const txData2 = {
+ user: userId,
+ conversationId: 'test-convo-2',
+ model: 'claude-3-5-sonnet',
+ context: 'test',
+ balance: { enabled: true },
+ };
+
+ const tokenUsage2 = {
+ promptTokens: {
+ input: 20,
+ write: 200,
+ read: 10,
+ },
+ completionTokens: 100,
+ };
+
+ await spendStructuredTokens(txData2, tokenUsage2);
+
+ // Check balance after second transaction - should still be 0
+ balance = await Balance.findOne({ user: userId });
+ expect(balance.tokenCredits).toBe(0);
+
+ // Verify all transactions were created
+ const transactions = await Transaction.find({ user: userId });
+ expect(transactions).toHaveLength(4); // 2 transactions (prompt+completion) for each call
+
+ // Let's examine the actual transaction records to see what's happening
+ const transactionDetails = await Transaction.find({ user: userId }).sort({ createdAt: 1 });
+
+ // Log the transaction details for debugging
+ console.log('Structured transaction details:');
+ transactionDetails.forEach((tx, i) => {
+ console.log(`Transaction ${i + 1}:`, {
+ tokenType: tx.tokenType,
+ rawAmount: tx.rawAmount,
+ tokenValue: tx.tokenValue,
+ inputTokens: tx.inputTokens,
+ writeTokens: tx.writeTokens,
+ readTokens: tx.readTokens,
+ model: tx.model,
+ });
+ });
+ });
+
+ it('should not allow balance to go below zero when spending structured tokens', async () => {
+ // Create a balance with a low amount
+ await Balance.create({
+ user: userId,
+ tokenCredits: 5000,
+ });
+
+ const txData = {
+ user: userId,
+ conversationId: 'test-convo',
+ model: 'claude-3-5-sonnet', // Using a model that supports structured tokens
+ context: 'test',
+ balance: { enabled: true },
+ };
+
+ // Spending more tokens than the user has balance for
+ const tokenUsage = {
+ promptTokens: {
+ input: 100,
+ write: 1000,
+ read: 50,
+ },
+ completionTokens: 500,
+ };
+
+ const result = await spendStructuredTokens(txData, tokenUsage);
+
+ // Verify transactions were created
+ const transactions = await Transaction.find({ user: userId }).sort({ tokenType: 1 });
+ expect(transactions).toHaveLength(2);
+
+ // Verify balance was reduced to exactly 0, not negative
+ const balance = await Balance.findOne({ user: userId });
+ expect(balance).toBeDefined();
+ expect(balance.tokenCredits).toBe(0);
+
+ // The result should show the adjusted values
+ expect(result).toEqual({
+ prompt: expect.objectContaining({
+ user: userId.toString(),
+ balance: expect.any(Number),
+ }),
+ completion: expect.objectContaining({
+ user: userId.toString(),
+ balance: 0, // Final balance should be 0
+ }),
+ });
+ });
+
+ it('should handle multiple concurrent transactions correctly with a high balance', async () => {
+ // Create a balance with a high amount
+ const initialBalance = 10000000;
+ await Balance.create({
+ user: userId,
+ tokenCredits: initialBalance,
+ });
+
+ // Simulate the recordCollectedUsage function from the production code
+ const conversationId = 'test-concurrent-convo';
+ const context = 'message';
+ const model = 'gpt-4';
+
+ const amount = 50;
+ // Create `amount` of usage records to simulate multiple transactions
+ const collectedUsage = Array.from({ length: amount }, (_, i) => ({
+ model,
+ input_tokens: 100 + i * 10, // Increasing input tokens
+ output_tokens: 50 + i * 5, // Increasing output tokens
+ input_token_details: {
+ cache_creation: i % 2 === 0 ? 20 : 0, // Some have cache creation
+ cache_read: i % 3 === 0 ? 10 : 0, // Some have cache read
+ },
+ }));
+
+ // Process all transactions concurrently to simulate race conditions
+ const promises = [];
+ let expectedTotalSpend = 0;
+
+ for (let i = 0; i < collectedUsage.length; i++) {
+ const usage = collectedUsage[i];
+ if (!usage) {
+ continue;
+ }
+
+ const cache_creation = Number(usage.input_token_details?.cache_creation) || 0;
+ const cache_read = Number(usage.input_token_details?.cache_read) || 0;
+
+ const txMetadata = {
+ context,
+ conversationId,
+ user: userId,
+ model: usage.model,
+ balance: { enabled: true },
+ };
+
+ // Calculate expected spend for this transaction
+ const promptTokens = usage.input_tokens;
+ const completionTokens = usage.output_tokens;
+
+ // For regular transactions
+ if (cache_creation === 0 && cache_read === 0) {
+ // Add to expected spend using the correct multipliers from tx.js
+ // For gpt-4, the multipliers are: prompt=30, completion=60
+ expectedTotalSpend += promptTokens * 30; // gpt-4 prompt rate is 30
+ expectedTotalSpend += completionTokens * 60; // gpt-4 completion rate is 60
+
+ promises.push(
+ spendTokens(txMetadata, {
+ promptTokens,
+ completionTokens,
+ }),
+ );
+ } else {
+ // For structured transactions with cache operations
+ // The multipliers for claude models with cache operations are different
+ // But since we're using gpt-4 in the test, we need to use appropriate values
+ expectedTotalSpend += promptTokens * 30; // Base prompt rate for gpt-4
+ // Since gpt-4 doesn't have cache multipliers defined, we'll use the prompt rate
+ expectedTotalSpend += cache_creation * 30; // Write rate (using prompt rate as fallback)
+ expectedTotalSpend += cache_read * 30; // Read rate (using prompt rate as fallback)
+ expectedTotalSpend += completionTokens * 60; // Completion rate for gpt-4
+
+ promises.push(
+ spendStructuredTokens(txMetadata, {
+ promptTokens: {
+ input: promptTokens,
+ write: cache_creation,
+ read: cache_read,
+ },
+ completionTokens,
+ }),
+ );
+ }
+ }
+
+ // Wait for all transactions to complete
+ await Promise.all(promises);
+
+ // Verify final balance
+ const finalBalance = await Balance.findOne({ user: userId });
+ expect(finalBalance).toBeDefined();
+
+ // The final balance should be the initial balance minus the expected total spend
+ const expectedFinalBalance = initialBalance - expectedTotalSpend;
+
+ console.log('Initial balance:', initialBalance);
+ console.log('Expected total spend:', expectedTotalSpend);
+ console.log('Expected final balance:', expectedFinalBalance);
+ console.log('Actual final balance:', finalBalance.tokenCredits);
+
+ // Allow for small rounding differences
+ expect(finalBalance.tokenCredits).toBeCloseTo(expectedFinalBalance, 0);
+
+ // Verify all transactions were created
+ const transactions = await Transaction.find({
+ user: userId,
+ conversationId,
+ });
+
+ // We should have 2 transactions (prompt + completion) for each usage record
+ // Some might be structured, some regular
+ expect(transactions.length).toBeGreaterThanOrEqual(collectedUsage.length);
+
+ // Log transaction details for debugging
+ console.log('Transaction summary:');
+ let totalTokenValue = 0;
+ transactions.forEach((tx) => {
+ console.log(`${tx.tokenType}: rawAmount=${tx.rawAmount}, tokenValue=${tx.tokenValue}`);
+ totalTokenValue += tx.tokenValue;
+ });
+ console.log('Total token value from transactions:', totalTokenValue);
+
+ // The difference between expected and actual is significant
+ // This is likely due to the multipliers being different in the test environment
+ // Let's adjust our expectation based on the actual transactions
+ const actualSpend = initialBalance - finalBalance.tokenCredits;
+ console.log('Actual spend:', actualSpend);
+
+ // Instead of checking the exact balance, let's verify that:
+ // 1. The balance was reduced (tokens were spent)
+ expect(finalBalance.tokenCredits).toBeLessThan(initialBalance);
+ // 2. The total token value from transactions matches the actual spend
+ expect(Math.abs(totalTokenValue)).toBeCloseTo(actualSpend, -3); // Allow for larger differences
+ });
+
+ // Add this new test case
+ it('should handle multiple concurrent balance increases correctly', async () => {
+ // Start with zero balance
+ const initialBalance = 0;
+ await Balance.create({
+ user: userId,
+ tokenCredits: initialBalance,
+ });
+
+ const numberOfRefills = 25;
+ const refillAmount = 1000;
+
+ const promises = [];
+ for (let i = 0; i < numberOfRefills; i++) {
+ promises.push(
+ createAutoRefillTransaction({
+ user: userId,
+ tokenType: 'credits',
+ context: 'concurrent-refill-test',
+ rawAmount: refillAmount,
+ balance: { enabled: true },
+ }),
+ );
+ }
+
+ // Wait for all refill transactions to complete
+ const results = await Promise.all(promises);
+
+ // Verify final balance
+ const finalBalance = await Balance.findOne({ user: userId });
+ expect(finalBalance).toBeDefined();
+
+ // The final balance should be the initial balance plus the sum of all refills
+ const expectedFinalBalance = initialBalance + numberOfRefills * refillAmount;
+
+ console.log('Initial balance (Increase Test):', initialBalance);
+ console.log(`Performed ${numberOfRefills} refills of ${refillAmount} each.`);
+ console.log('Expected final balance (Increase Test):', expectedFinalBalance);
+ console.log('Actual final balance (Increase Test):', finalBalance.tokenCredits);
+
+ // Use toBeCloseTo for safety, though toBe should work for integer math
+ expect(finalBalance.tokenCredits).toBeCloseTo(expectedFinalBalance, 0);
+
+ // Verify all transactions were created
+ const transactions = await Transaction.find({
+ user: userId,
+ context: 'concurrent-refill-test',
+ });
+
+ // We should have one transaction for each refill attempt
+ expect(transactions.length).toBe(numberOfRefills);
+
+ // Optional: Verify the sum of increments from the results matches the balance change
+ const totalIncrementReported = results.reduce((sum, result) => {
+ // Assuming createAutoRefillTransaction returns an object with the increment amount
+ // Adjust this based on the actual return structure.
+ // Let's assume it returns { balance: newBalance, transaction: { rawAmount: ... } }
+ // Or perhaps we check the transaction.rawAmount directly
+ return sum + (result?.transaction?.rawAmount || 0);
+ }, 0);
+ console.log('Total increment reported by results:', totalIncrementReported);
+ expect(totalIncrementReported).toBe(expectedFinalBalance - initialBalance);
+
+ // Optional: Check the sum of tokenValue from saved transactions
+ let totalTokenValueFromDb = 0;
+ transactions.forEach((tx) => {
+ // For refills, rawAmount is positive, and tokenValue might be calculated based on it
+ // Let's assume tokenValue directly reflects the increment for simplicity here
+ // If calculation is involved, adjust accordingly
+ totalTokenValueFromDb += tx.rawAmount; // Or tx.tokenValue if that holds the increment
+ });
+ console.log('Total rawAmount from DB transactions:', totalTokenValueFromDb);
+ expect(totalTokenValueFromDb).toBeCloseTo(expectedFinalBalance - initialBalance, 0);
+ });
+
+ it('should create structured transactions for both prompt and completion tokens', async () => {
+ // Create a balance for the user
+ await Balance.create({
+ user: userId,
+ tokenCredits: 10000,
+ });
+
+ const txData = {
+ user: userId,
+ conversationId: 'test-convo',
+ model: 'claude-3-5-sonnet',
+ context: 'test',
+ balance: { enabled: true },
+ };
+ const tokenUsage = {
+ promptTokens: {
+ input: 10,
+ write: 100,
+ read: 5,
+ },
+ completionTokens: 50,
+ };
+
+ const result = await spendStructuredTokens(txData, tokenUsage);
+
+ // Verify transactions were created
+ const transactions = await Transaction.find({ user: userId }).sort({ tokenType: 1 });
+ expect(transactions).toHaveLength(2);
+
+ // Check completion transaction
+ expect(transactions[0].tokenType).toBe('completion');
+ expect(transactions[0].rawAmount).toBe(-50);
+
+ // Check prompt transaction
+ expect(transactions[1].tokenType).toBe('prompt');
+ expect(transactions[1].inputTokens).toBe(-10);
+ expect(transactions[1].writeTokens).toBe(-100);
+ expect(transactions[1].readTokens).toBe(-5);
+
+ // Verify result contains transaction info
+ expect(result).toEqual({
+ prompt: expect.objectContaining({
+ user: userId.toString(),
+ prompt: expect.any(Number),
+ }),
+ completion: expect.objectContaining({
+ user: userId.toString(),
+ completion: expect.any(Number),
+ }),
+ });
+
+ // Verify balance was updated
+ const balance = await Balance.findOne({ user: userId });
+ expect(balance).toBeDefined();
+ expect(balance.tokenCredits).toBeLessThan(10000); // Balance should be reduced
+ });
+});
diff --git a/api/models/tx.js b/api/models/tx.js
new file mode 100644
index 0000000000..aa213d3475
--- /dev/null
+++ b/api/models/tx.js
@@ -0,0 +1,377 @@
+const { matchModelName, findMatchingPattern } = require('@librechat/api');
+const defaultRate = 6;
+
+/**
+ * AWS Bedrock pricing
+ * source: https://aws.amazon.com/bedrock/pricing/
+ * */
+const bedrockValues = {
+ // Basic llama2 patterns (base defaults to smallest variant)
+ llama2: { prompt: 0.75, completion: 1.0 },
+ 'llama-2': { prompt: 0.75, completion: 1.0 },
+ 'llama2-13b': { prompt: 0.75, completion: 1.0 },
+ 'llama2:70b': { prompt: 1.95, completion: 2.56 },
+ 'llama2-70b': { prompt: 1.95, completion: 2.56 },
+
+ // Basic llama3 patterns (base defaults to smallest variant)
+ llama3: { prompt: 0.3, completion: 0.6 },
+ 'llama-3': { prompt: 0.3, completion: 0.6 },
+ 'llama3-8b': { prompt: 0.3, completion: 0.6 },
+ 'llama3:8b': { prompt: 0.3, completion: 0.6 },
+ 'llama3-70b': { prompt: 2.65, completion: 3.5 },
+ 'llama3:70b': { prompt: 2.65, completion: 3.5 },
+
+ // llama3-x-Nb pattern (base defaults to smallest variant)
+ 'llama3-1': { prompt: 0.22, completion: 0.22 },
+ 'llama3-1-8b': { prompt: 0.22, completion: 0.22 },
+ 'llama3-1-70b': { prompt: 0.72, completion: 0.72 },
+ 'llama3-1-405b': { prompt: 2.4, completion: 2.4 },
+ 'llama3-2': { prompt: 0.1, completion: 0.1 },
+ 'llama3-2-1b': { prompt: 0.1, completion: 0.1 },
+ 'llama3-2-3b': { prompt: 0.15, completion: 0.15 },
+ 'llama3-2-11b': { prompt: 0.16, completion: 0.16 },
+ 'llama3-2-90b': { prompt: 0.72, completion: 0.72 },
+ 'llama3-3': { prompt: 2.65, completion: 3.5 },
+ 'llama3-3-70b': { prompt: 2.65, completion: 3.5 },
+
+ // llama3.x:Nb pattern (base defaults to smallest variant)
+ 'llama3.1': { prompt: 0.22, completion: 0.22 },
+ 'llama3.1:8b': { prompt: 0.22, completion: 0.22 },
+ 'llama3.1:70b': { prompt: 0.72, completion: 0.72 },
+ 'llama3.1:405b': { prompt: 2.4, completion: 2.4 },
+ 'llama3.2': { prompt: 0.1, completion: 0.1 },
+ 'llama3.2:1b': { prompt: 0.1, completion: 0.1 },
+ 'llama3.2:3b': { prompt: 0.15, completion: 0.15 },
+ 'llama3.2:11b': { prompt: 0.16, completion: 0.16 },
+ 'llama3.2:90b': { prompt: 0.72, completion: 0.72 },
+ 'llama3.3': { prompt: 2.65, completion: 3.5 },
+ 'llama3.3:70b': { prompt: 2.65, completion: 3.5 },
+
+ // llama-3.x-Nb pattern (base defaults to smallest variant)
+ 'llama-3.1': { prompt: 0.22, completion: 0.22 },
+ 'llama-3.1-8b': { prompt: 0.22, completion: 0.22 },
+ 'llama-3.1-70b': { prompt: 0.72, completion: 0.72 },
+ 'llama-3.1-405b': { prompt: 2.4, completion: 2.4 },
+ 'llama-3.2': { prompt: 0.1, completion: 0.1 },
+ 'llama-3.2-1b': { prompt: 0.1, completion: 0.1 },
+ 'llama-3.2-3b': { prompt: 0.15, completion: 0.15 },
+ 'llama-3.2-11b': { prompt: 0.16, completion: 0.16 },
+ 'llama-3.2-90b': { prompt: 0.72, completion: 0.72 },
+ 'llama-3.3': { prompt: 2.65, completion: 3.5 },
+ 'llama-3.3-70b': { prompt: 2.65, completion: 3.5 },
+ 'mistral-7b': { prompt: 0.15, completion: 0.2 },
+ 'mistral-small': { prompt: 0.15, completion: 0.2 },
+ 'mixtral-8x7b': { prompt: 0.45, completion: 0.7 },
+ 'mistral-large-2402': { prompt: 4.0, completion: 12.0 },
+ 'mistral-large-2407': { prompt: 3.0, completion: 9.0 },
+ 'command-text': { prompt: 1.5, completion: 2.0 },
+ 'command-light': { prompt: 0.3, completion: 0.6 },
+ // AI21 models
+ 'j2-mid': { prompt: 12.5, completion: 12.5 },
+ 'j2-ultra': { prompt: 18.8, completion: 18.8 },
+ 'jamba-instruct': { prompt: 0.5, completion: 0.7 },
+ // Amazon Titan models
+ 'titan-text-lite': { prompt: 0.15, completion: 0.2 },
+ 'titan-text-express': { prompt: 0.2, completion: 0.6 },
+ 'titan-text-premier': { prompt: 0.5, completion: 1.5 },
+ // Amazon Nova models
+ 'nova-micro': { prompt: 0.035, completion: 0.14 },
+ 'nova-lite': { prompt: 0.06, completion: 0.24 },
+ 'nova-pro': { prompt: 0.8, completion: 3.2 },
+ 'nova-premier': { prompt: 2.5, completion: 12.5 },
+ 'deepseek.r1': { prompt: 1.35, completion: 5.4 },
+};
+
+/**
+ * Mapping of model token sizes to their respective multipliers for prompt and completion.
+ * The rates are 1 USD per 1M tokens.
+ * @type {Object.}
+ */
+const tokenValues = Object.assign(
+ {
+ // Legacy token size mappings (generic patterns - check LAST)
+ '8k': { prompt: 30, completion: 60 },
+ '32k': { prompt: 60, completion: 120 },
+ '4k': { prompt: 1.5, completion: 2 },
+ '16k': { prompt: 3, completion: 4 },
+ // Generic fallback patterns (check LAST)
+ 'claude-': { prompt: 0.8, completion: 2.4 },
+ deepseek: { prompt: 0.28, completion: 0.42 },
+ command: { prompt: 0.38, completion: 0.38 },
+ gemma: { prompt: 0.02, completion: 0.04 }, // Base pattern (using gemma-3n-e4b pricing)
+ gemini: { prompt: 0.5, completion: 1.5 },
+ 'gpt-oss': { prompt: 0.05, completion: 0.2 },
+ // Specific model variants (check FIRST - more specific patterns at end)
+ 'gpt-3.5-turbo-1106': { prompt: 1, completion: 2 },
+ 'gpt-3.5-turbo-0125': { prompt: 0.5, completion: 1.5 },
+ 'gpt-4-1106': { prompt: 10, completion: 30 },
+ 'gpt-4.1': { prompt: 2, completion: 8 },
+ 'gpt-4.1-nano': { prompt: 0.1, completion: 0.4 },
+ 'gpt-4.1-mini': { prompt: 0.4, completion: 1.6 },
+ 'gpt-4.5': { prompt: 75, completion: 150 },
+ 'gpt-4o': { prompt: 2.5, completion: 10 },
+ 'gpt-4o-2024-05-13': { prompt: 5, completion: 15 },
+ 'gpt-4o-mini': { prompt: 0.15, completion: 0.6 },
+ 'gpt-5': { prompt: 1.25, completion: 10 },
+ 'gpt-5-nano': { prompt: 0.05, completion: 0.4 },
+ 'gpt-5-mini': { prompt: 0.25, completion: 2 },
+ 'gpt-5-pro': { prompt: 15, completion: 120 },
+ o1: { prompt: 15, completion: 60 },
+ 'o1-mini': { prompt: 1.1, completion: 4.4 },
+ 'o1-preview': { prompt: 15, completion: 60 },
+ o3: { prompt: 2, completion: 8 },
+ 'o3-mini': { prompt: 1.1, completion: 4.4 },
+ 'o4-mini': { prompt: 1.1, completion: 4.4 },
+ 'claude-instant': { prompt: 0.8, completion: 2.4 },
+ 'claude-2': { prompt: 8, completion: 24 },
+ 'claude-2.1': { prompt: 8, completion: 24 },
+ 'claude-3-haiku': { prompt: 0.25, completion: 1.25 },
+ 'claude-3-sonnet': { prompt: 3, completion: 15 },
+ 'claude-3-opus': { prompt: 15, completion: 75 },
+ 'claude-3-5-haiku': { prompt: 0.8, completion: 4 },
+ 'claude-3.5-haiku': { prompt: 0.8, completion: 4 },
+ 'claude-3-5-sonnet': { prompt: 3, completion: 15 },
+ 'claude-3.5-sonnet': { prompt: 3, completion: 15 },
+ 'claude-3-7-sonnet': { prompt: 3, completion: 15 },
+ 'claude-3.7-sonnet': { prompt: 3, completion: 15 },
+ 'claude-haiku-4-5': { prompt: 1, completion: 5 },
+ 'claude-opus-4': { prompt: 15, completion: 75 },
+ 'claude-opus-4-5': { prompt: 5, completion: 25 },
+ 'claude-sonnet-4': { prompt: 3, completion: 15 },
+ 'command-r': { prompt: 0.5, completion: 1.5 },
+ 'command-r-plus': { prompt: 3, completion: 15 },
+ 'command-text': { prompt: 1.5, completion: 2.0 },
+ 'deepseek-chat': { prompt: 0.28, completion: 0.42 },
+ 'deepseek-reasoner': { prompt: 0.28, completion: 0.42 },
+ 'deepseek-r1': { prompt: 0.4, completion: 2.0 },
+ 'deepseek-v3': { prompt: 0.2, completion: 0.8 },
+ 'gemma-2': { prompt: 0.01, completion: 0.03 }, // Base pattern (using gemma-2-9b pricing)
+ 'gemma-3': { prompt: 0.02, completion: 0.04 }, // Base pattern (using gemma-3n-e4b pricing)
+ 'gemma-3-27b': { prompt: 0.09, completion: 0.16 },
+ 'gemini-1.5': { prompt: 2.5, completion: 10 },
+ 'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 },
+ 'gemini-1.5-flash-8b': { prompt: 0.075, completion: 0.3 },
+ 'gemini-2.0': { prompt: 0.1, completion: 0.4 }, // Base pattern (using 2.0-flash pricing)
+ 'gemini-2.0-flash': { prompt: 0.1, completion: 0.4 },
+ 'gemini-2.0-flash-lite': { prompt: 0.075, completion: 0.3 },
+ 'gemini-2.5': { prompt: 0.3, completion: 2.5 }, // Base pattern (using 2.5-flash pricing)
+ 'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 },
+ 'gemini-2.5-flash-lite': { prompt: 0.1, completion: 0.4 },
+ 'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
+ 'gemini-3': { prompt: 2, completion: 12 },
+ 'gemini-pro-vision': { prompt: 0.5, completion: 1.5 },
+ grok: { prompt: 2.0, completion: 10.0 }, // Base pattern defaults to grok-2
+ 'grok-beta': { prompt: 5.0, completion: 15.0 },
+ 'grok-vision-beta': { prompt: 5.0, completion: 15.0 },
+ 'grok-2': { prompt: 2.0, completion: 10.0 },
+ 'grok-2-1212': { prompt: 2.0, completion: 10.0 },
+ 'grok-2-latest': { prompt: 2.0, completion: 10.0 },
+ 'grok-2-vision': { prompt: 2.0, completion: 10.0 },
+ 'grok-2-vision-1212': { prompt: 2.0, completion: 10.0 },
+ 'grok-2-vision-latest': { prompt: 2.0, completion: 10.0 },
+ 'grok-3': { prompt: 3.0, completion: 15.0 },
+ 'grok-3-fast': { prompt: 5.0, completion: 25.0 },
+ 'grok-3-mini': { prompt: 0.3, completion: 0.5 },
+ 'grok-3-mini-fast': { prompt: 0.6, completion: 4 },
+ 'grok-4': { prompt: 3.0, completion: 15.0 },
+ 'grok-4-fast': { prompt: 0.2, completion: 0.5 },
+ 'grok-4-1-fast': { prompt: 0.2, completion: 0.5 }, // covers reasoning & non-reasoning variants
+ 'grok-code-fast': { prompt: 0.2, completion: 1.5 },
+ codestral: { prompt: 0.3, completion: 0.9 },
+ 'ministral-3b': { prompt: 0.04, completion: 0.04 },
+ 'ministral-8b': { prompt: 0.1, completion: 0.1 },
+ 'mistral-nemo': { prompt: 0.15, completion: 0.15 },
+ 'mistral-saba': { prompt: 0.2, completion: 0.6 },
+ 'pixtral-large': { prompt: 2.0, completion: 6.0 },
+ 'mistral-large': { prompt: 2.0, completion: 6.0 },
+ 'mixtral-8x22b': { prompt: 0.65, completion: 0.65 },
+ kimi: { prompt: 0.14, completion: 2.49 }, // Base pattern (using kimi-k2 pricing)
+ // GPT-OSS models (specific sizes)
+ 'gpt-oss:20b': { prompt: 0.05, completion: 0.2 },
+ 'gpt-oss-20b': { prompt: 0.05, completion: 0.2 },
+ 'gpt-oss:120b': { prompt: 0.15, completion: 0.6 },
+ 'gpt-oss-120b': { prompt: 0.15, completion: 0.6 },
+ // GLM models (Zhipu AI) - general to specific
+ glm4: { prompt: 0.1, completion: 0.1 },
+ 'glm-4': { prompt: 0.1, completion: 0.1 },
+ 'glm-4-32b': { prompt: 0.1, completion: 0.1 },
+ 'glm-4.5': { prompt: 0.35, completion: 1.55 },
+ 'glm-4.5-air': { prompt: 0.14, completion: 0.86 },
+ 'glm-4.5v': { prompt: 0.6, completion: 1.8 },
+ 'glm-4.6': { prompt: 0.5, completion: 1.75 },
+ // Qwen models
+ qwen: { prompt: 0.08, completion: 0.33 }, // Qwen base pattern (using qwen2.5-72b pricing)
+ 'qwen2.5': { prompt: 0.08, completion: 0.33 }, // Qwen 2.5 base pattern
+ 'qwen-turbo': { prompt: 0.05, completion: 0.2 },
+ 'qwen-plus': { prompt: 0.4, completion: 1.2 },
+ 'qwen-max': { prompt: 1.6, completion: 6.4 },
+ 'qwq-32b': { prompt: 0.15, completion: 0.4 },
+ // Qwen3 models
+ qwen3: { prompt: 0.035, completion: 0.138 }, // Qwen3 base pattern (using qwen3-4b pricing)
+ 'qwen3-8b': { prompt: 0.035, completion: 0.138 },
+ 'qwen3-14b': { prompt: 0.05, completion: 0.22 },
+ 'qwen3-30b-a3b': { prompt: 0.06, completion: 0.22 },
+ 'qwen3-32b': { prompt: 0.05, completion: 0.2 },
+ 'qwen3-235b-a22b': { prompt: 0.08, completion: 0.55 },
+ // Qwen3 VL (Vision-Language) models
+ 'qwen3-vl-8b-thinking': { prompt: 0.18, completion: 2.1 },
+ 'qwen3-vl-8b-instruct': { prompt: 0.18, completion: 0.69 },
+ 'qwen3-vl-30b-a3b': { prompt: 0.29, completion: 1.0 },
+ 'qwen3-vl-235b-a22b': { prompt: 0.3, completion: 1.2 },
+ // Qwen3 specialized models
+ 'qwen3-max': { prompt: 1.2, completion: 6 },
+ 'qwen3-coder': { prompt: 0.22, completion: 0.95 },
+ 'qwen3-coder-30b-a3b': { prompt: 0.06, completion: 0.25 },
+ 'qwen3-coder-plus': { prompt: 1, completion: 5 },
+ 'qwen3-coder-flash': { prompt: 0.3, completion: 1.5 },
+ 'qwen3-next-80b-a3b': { prompt: 0.1, completion: 0.8 },
+ },
+ bedrockValues,
+);
+
+/**
+ * Mapping of model token sizes to their respective multipliers for cached input, read and write.
+ * See Anthropic's documentation on this: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#pricing
+ * The rates are 1 USD per 1M tokens.
+ * @type {Object.}
+ */
+const cacheTokenValues = {
+ 'claude-3.7-sonnet': { write: 3.75, read: 0.3 },
+ 'claude-3-7-sonnet': { write: 3.75, read: 0.3 },
+ 'claude-3.5-sonnet': { write: 3.75, read: 0.3 },
+ 'claude-3-5-sonnet': { write: 3.75, read: 0.3 },
+ 'claude-3.5-haiku': { write: 1, read: 0.08 },
+ 'claude-3-5-haiku': { write: 1, read: 0.08 },
+ 'claude-3-haiku': { write: 0.3, read: 0.03 },
+ 'claude-haiku-4-5': { write: 1.25, read: 0.1 },
+ 'claude-sonnet-4': { write: 3.75, read: 0.3 },
+ 'claude-opus-4': { write: 18.75, read: 1.5 },
+ 'claude-opus-4-5': { write: 6.25, read: 0.5 },
+ // DeepSeek models - cache hit: $0.028/1M, cache miss: $0.28/1M
+ deepseek: { write: 0.28, read: 0.028 },
+ 'deepseek-chat': { write: 0.28, read: 0.028 },
+ 'deepseek-reasoner': { write: 0.28, read: 0.028 },
+};
+
+/**
+ * Retrieves the key associated with a given model name.
+ *
+ * @param {string} model - The model name to match.
+ * @param {string} endpoint - The endpoint name to match.
+ * @returns {string|undefined} The key corresponding to the model name, or undefined if no match is found.
+ */
+const getValueKey = (model, endpoint) => {
+ if (!model || typeof model !== 'string') {
+ return undefined;
+ }
+
+ // Use findMatchingPattern directly against tokenValues for efficient lookup
+ if (!endpoint || (typeof endpoint === 'string' && !tokenValues[endpoint])) {
+ const matchedKey = findMatchingPattern(model, tokenValues);
+ if (matchedKey) {
+ return matchedKey;
+ }
+ }
+
+ // Fallback: use matchModelName for edge cases and legacy handling
+ const modelName = matchModelName(model, endpoint);
+ if (!modelName) {
+ return undefined;
+ }
+
+ // Legacy token size mappings and aliases for older models
+ if (modelName.includes('gpt-3.5-turbo-16k')) {
+ return '16k';
+ } else if (modelName.includes('gpt-3.5')) {
+ return '4k';
+ } else if (modelName.includes('gpt-4-vision')) {
+ return 'gpt-4-1106'; // Alias for gpt-4-vision
+ } else if (modelName.includes('gpt-4-0125')) {
+ return 'gpt-4-1106'; // Alias for gpt-4-0125
+ } else if (modelName.includes('gpt-4-turbo')) {
+ return 'gpt-4-1106'; // Alias for gpt-4-turbo
+ } else if (modelName.includes('gpt-4-32k')) {
+ return '32k';
+ } else if (modelName.includes('gpt-4')) {
+ return '8k';
+ }
+
+ return undefined;
+};
+
+/**
+ * Retrieves the multiplier for a given value key and token type. If no value key is provided,
+ * it attempts to derive it from the model name.
+ *
+ * @param {Object} params - The parameters for the function.
+ * @param {string} [params.valueKey] - The key corresponding to the model name.
+ * @param {'prompt' | 'completion'} [params.tokenType] - The type of token (e.g., 'prompt' or 'completion').
+ * @param {string} [params.model] - The model name to derive the value key from if not provided.
+ * @param {string} [params.endpoint] - The endpoint name to derive the value key from if not provided.
+ * @param {EndpointTokenConfig} [params.endpointTokenConfig] - The token configuration for the endpoint.
+ * @returns {number} The multiplier for the given parameters, or a default value if not found.
+ */
+const getMultiplier = ({ valueKey, tokenType, model, endpoint, endpointTokenConfig }) => {
+ if (endpointTokenConfig) {
+ return endpointTokenConfig?.[model]?.[tokenType] ?? defaultRate;
+ }
+
+ if (valueKey && tokenType) {
+ return tokenValues[valueKey][tokenType] ?? defaultRate;
+ }
+
+ if (!tokenType || !model) {
+ return 1;
+ }
+
+ valueKey = getValueKey(model, endpoint);
+ if (!valueKey) {
+ return defaultRate;
+ }
+
+ // If we got this far, and values[tokenType] is undefined somehow, return a rough average of default multipliers
+ return tokenValues[valueKey]?.[tokenType] ?? defaultRate;
+};
+
+/**
+ * Retrieves the cache multiplier for a given value key and token type. If no value key is provided,
+ * it attempts to derive it from the model name.
+ *
+ * @param {Object} params - The parameters for the function.
+ * @param {string} [params.valueKey] - The key corresponding to the model name.
+ * @param {'write' | 'read'} [params.cacheType] - The type of token (e.g., 'write' or 'read').
+ * @param {string} [params.model] - The model name to derive the value key from if not provided.
+ * @param {string} [params.endpoint] - The endpoint name to derive the value key from if not provided.
+ * @param {EndpointTokenConfig} [params.endpointTokenConfig] - The token configuration for the endpoint.
+ * @returns {number | null} The multiplier for the given parameters, or `null` if not found.
+ */
+const getCacheMultiplier = ({ valueKey, cacheType, model, endpoint, endpointTokenConfig }) => {
+ if (endpointTokenConfig) {
+ return endpointTokenConfig?.[model]?.[cacheType] ?? null;
+ }
+
+ if (valueKey && cacheType) {
+ return cacheTokenValues[valueKey]?.[cacheType] ?? null;
+ }
+
+ if (!cacheType || !model) {
+ return null;
+ }
+
+ valueKey = getValueKey(model, endpoint);
+ if (!valueKey) {
+ return null;
+ }
+
+ // If we got this far, and values[cacheType] is undefined somehow, return a rough average of default multipliers
+ return cacheTokenValues[valueKey]?.[cacheType] ?? null;
+};
+
+module.exports = {
+ tokenValues,
+ getValueKey,
+ getMultiplier,
+ getCacheMultiplier,
+ defaultRate,
+ cacheTokenValues,
+};
diff --git a/api/models/tx.spec.js b/api/models/tx.spec.js
new file mode 100644
index 0000000000..18030abb21
--- /dev/null
+++ b/api/models/tx.spec.js
@@ -0,0 +1,1767 @@
+const { maxTokensMap } = require('@librechat/api');
+const { EModelEndpoint } = require('librechat-data-provider');
+const {
+ defaultRate,
+ tokenValues,
+ getValueKey,
+ getMultiplier,
+ cacheTokenValues,
+ getCacheMultiplier,
+} = require('./tx');
+
+describe('getValueKey', () => {
+ it('should return "16k" for model name containing "gpt-3.5-turbo-16k"', () => {
+ expect(getValueKey('gpt-3.5-turbo-16k-some-other-info')).toBe('16k');
+ });
+
+ it('should return "4k" for model name containing "gpt-3.5"', () => {
+ expect(getValueKey('gpt-3.5-some-other-info')).toBe('4k');
+ });
+
+ it('should return "32k" for model name containing "gpt-4-32k"', () => {
+ expect(getValueKey('gpt-4-32k-some-other-info')).toBe('32k');
+ });
+
+ it('should return "8k" for model name containing "gpt-4"', () => {
+ expect(getValueKey('gpt-4-some-other-info')).toBe('8k');
+ });
+
+ it('should return "gpt-5" for model name containing "gpt-5"', () => {
+ expect(getValueKey('gpt-5-some-other-info')).toBe('gpt-5');
+ expect(getValueKey('gpt-5-2025-01-30')).toBe('gpt-5');
+ expect(getValueKey('gpt-5-2025-01-30-0130')).toBe('gpt-5');
+ expect(getValueKey('openai/gpt-5')).toBe('gpt-5');
+ expect(getValueKey('openai/gpt-5-2025-01-30')).toBe('gpt-5');
+ expect(getValueKey('gpt-5-turbo')).toBe('gpt-5');
+ expect(getValueKey('gpt-5-0130')).toBe('gpt-5');
+ });
+
+ it('should return "gpt-3.5-turbo-1106" for model name containing "gpt-3.5-turbo-1106"', () => {
+ expect(getValueKey('gpt-3.5-turbo-1106-some-other-info')).toBe('gpt-3.5-turbo-1106');
+ expect(getValueKey('openai/gpt-3.5-turbo-1106')).toBe('gpt-3.5-turbo-1106');
+ expect(getValueKey('gpt-3.5-turbo-1106/openai')).toBe('gpt-3.5-turbo-1106');
+ });
+
+ it('should return "gpt-4-1106" for model name containing "gpt-4-1106"', () => {
+ expect(getValueKey('gpt-4-1106-some-other-info')).toBe('gpt-4-1106');
+ expect(getValueKey('gpt-4-1106-vision-preview')).toBe('gpt-4-1106');
+ expect(getValueKey('gpt-4-1106-preview')).toBe('gpt-4-1106');
+ expect(getValueKey('openai/gpt-4-1106')).toBe('gpt-4-1106');
+ expect(getValueKey('gpt-4-1106/openai/')).toBe('gpt-4-1106');
+ });
+
+ it('should return "gpt-4-1106" for model type of "gpt-4-1106"', () => {
+ expect(getValueKey('gpt-4-vision-preview')).toBe('gpt-4-1106');
+ expect(getValueKey('openai/gpt-4-1106')).toBe('gpt-4-1106');
+ expect(getValueKey('gpt-4-turbo')).toBe('gpt-4-1106');
+ expect(getValueKey('gpt-4-0125')).toBe('gpt-4-1106');
+ });
+
+ it('should return "gpt-4.5" for model type of "gpt-4.5"', () => {
+ expect(getValueKey('gpt-4.5-preview')).toBe('gpt-4.5');
+ expect(getValueKey('gpt-4.5-2024-08-06')).toBe('gpt-4.5');
+ expect(getValueKey('gpt-4.5-2024-08-06-0718')).toBe('gpt-4.5');
+ expect(getValueKey('openai/gpt-4.5')).toBe('gpt-4.5');
+ expect(getValueKey('openai/gpt-4.5-2024-08-06')).toBe('gpt-4.5');
+ expect(getValueKey('gpt-4.5-turbo')).toBe('gpt-4.5');
+ 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-5" for model type of "gpt-5"', () => {
+ expect(getValueKey('gpt-5-2025-01-30')).toBe('gpt-5');
+ expect(getValueKey('gpt-5-2025-01-30-0130')).toBe('gpt-5');
+ expect(getValueKey('openai/gpt-5')).toBe('gpt-5');
+ expect(getValueKey('openai/gpt-5-2025-01-30')).toBe('gpt-5');
+ expect(getValueKey('gpt-5-turbo')).toBe('gpt-5');
+ expect(getValueKey('gpt-5-0130')).toBe('gpt-5');
+ });
+
+ it('should return "gpt-5-mini" for model type of "gpt-5-mini"', () => {
+ expect(getValueKey('gpt-5-mini-2025-01-30')).toBe('gpt-5-mini');
+ expect(getValueKey('openai/gpt-5-mini')).toBe('gpt-5-mini');
+ expect(getValueKey('gpt-5-mini-0130')).toBe('gpt-5-mini');
+ expect(getValueKey('gpt-5-mini-2025-01-30-0130')).toBe('gpt-5-mini');
+ });
+
+ it('should return "gpt-5-nano" for model type of "gpt-5-nano"', () => {
+ expect(getValueKey('gpt-5-nano-2025-01-30')).toBe('gpt-5-nano');
+ expect(getValueKey('openai/gpt-5-nano')).toBe('gpt-5-nano');
+ expect(getValueKey('gpt-5-nano-0130')).toBe('gpt-5-nano');
+ expect(getValueKey('gpt-5-nano-2025-01-30-0130')).toBe('gpt-5-nano');
+ });
+
+ it('should return "gpt-5-pro" for model type of "gpt-5-pro"', () => {
+ expect(getValueKey('gpt-5-pro-2025-01-30')).toBe('gpt-5-pro');
+ expect(getValueKey('openai/gpt-5-pro')).toBe('gpt-5-pro');
+ expect(getValueKey('gpt-5-pro-0130')).toBe('gpt-5-pro');
+ expect(getValueKey('gpt-5-pro-2025-01-30-0130')).toBe('gpt-5-pro');
+ expect(getValueKey('gpt-5-pro-preview')).toBe('gpt-5-pro');
+ });
+
+ it('should return "gpt-4o" for model type of "gpt-4o"', () => {
+ expect(getValueKey('gpt-4o-2024-08-06')).toBe('gpt-4o');
+ expect(getValueKey('gpt-4o-2024-08-06-0718')).toBe('gpt-4o');
+ expect(getValueKey('openai/gpt-4o')).toBe('gpt-4o');
+ expect(getValueKey('openai/gpt-4o-2024-08-06')).toBe('gpt-4o');
+ expect(getValueKey('gpt-4o-turbo')).toBe('gpt-4o');
+ expect(getValueKey('gpt-4o-0125')).toBe('gpt-4o');
+ });
+
+ it('should return "gpt-4o-mini" for model type of "gpt-4o-mini"', () => {
+ expect(getValueKey('gpt-4o-mini-2024-07-18')).toBe('gpt-4o-mini');
+ expect(getValueKey('openai/gpt-4o-mini')).toBe('gpt-4o-mini');
+ expect(getValueKey('gpt-4o-mini-0718')).toBe('gpt-4o-mini');
+ expect(getValueKey('gpt-4o-2024-08-06-0718')).not.toBe('gpt-4o-mini');
+ });
+
+ it('should return "gpt-4o-2024-05-13" for model type of "gpt-4o-2024-05-13"', () => {
+ expect(getValueKey('gpt-4o-2024-05-13')).toBe('gpt-4o-2024-05-13');
+ expect(getValueKey('openai/gpt-4o-2024-05-13')).toBe('gpt-4o-2024-05-13');
+ expect(getValueKey('gpt-4o-2024-05-13-0718')).toBe('gpt-4o-2024-05-13');
+ expect(getValueKey('gpt-4o-2024-05-13-0718')).not.toBe('gpt-4o');
+ });
+
+ it('should return "gpt-4o" for model type of "chatgpt-4o"', () => {
+ expect(getValueKey('chatgpt-4o-latest')).toBe('gpt-4o');
+ expect(getValueKey('openai/chatgpt-4o-latest')).toBe('gpt-4o');
+ expect(getValueKey('chatgpt-4o-latest-0916')).toBe('gpt-4o');
+ expect(getValueKey('chatgpt-4o-latest-0718')).toBe('gpt-4o');
+ });
+
+ it('should return "claude-3-7-sonnet" for model type of "claude-3-7-sonnet-"', () => {
+ expect(getValueKey('claude-3-7-sonnet-20240620')).toBe('claude-3-7-sonnet');
+ expect(getValueKey('anthropic/claude-3-7-sonnet')).toBe('claude-3-7-sonnet');
+ expect(getValueKey('claude-3-7-sonnet-turbo')).toBe('claude-3-7-sonnet');
+ expect(getValueKey('claude-3-7-sonnet-0125')).toBe('claude-3-7-sonnet');
+ });
+
+ it('should return "claude-3.7-sonnet" for model type of "claude-3.7-sonnet-"', () => {
+ expect(getValueKey('claude-3.7-sonnet-20240620')).toBe('claude-3.7-sonnet');
+ expect(getValueKey('anthropic/claude-3.7-sonnet')).toBe('claude-3.7-sonnet');
+ expect(getValueKey('claude-3.7-sonnet-turbo')).toBe('claude-3.7-sonnet');
+ expect(getValueKey('claude-3.7-sonnet-0125')).toBe('claude-3.7-sonnet');
+ });
+
+ it('should return "claude-3-5-sonnet" for model type of "claude-3-5-sonnet-"', () => {
+ expect(getValueKey('claude-3-5-sonnet-20240620')).toBe('claude-3-5-sonnet');
+ expect(getValueKey('anthropic/claude-3-5-sonnet')).toBe('claude-3-5-sonnet');
+ expect(getValueKey('claude-3-5-sonnet-turbo')).toBe('claude-3-5-sonnet');
+ expect(getValueKey('claude-3-5-sonnet-0125')).toBe('claude-3-5-sonnet');
+ });
+
+ it('should return "claude-3.5-sonnet" for model type of "claude-3.5-sonnet-"', () => {
+ expect(getValueKey('claude-3.5-sonnet-20240620')).toBe('claude-3.5-sonnet');
+ expect(getValueKey('anthropic/claude-3.5-sonnet')).toBe('claude-3.5-sonnet');
+ expect(getValueKey('claude-3.5-sonnet-turbo')).toBe('claude-3.5-sonnet');
+ expect(getValueKey('claude-3.5-sonnet-0125')).toBe('claude-3.5-sonnet');
+ });
+
+ it('should return "claude-3-5-haiku" for model type of "claude-3-5-haiku-"', () => {
+ expect(getValueKey('claude-3-5-haiku-20240620')).toBe('claude-3-5-haiku');
+ expect(getValueKey('anthropic/claude-3-5-haiku')).toBe('claude-3-5-haiku');
+ expect(getValueKey('claude-3-5-haiku-turbo')).toBe('claude-3-5-haiku');
+ expect(getValueKey('claude-3-5-haiku-0125')).toBe('claude-3-5-haiku');
+ });
+
+ it('should return "claude-3.5-haiku" for model type of "claude-3.5-haiku-"', () => {
+ expect(getValueKey('claude-3.5-haiku-20240620')).toBe('claude-3.5-haiku');
+ expect(getValueKey('anthropic/claude-3.5-haiku')).toBe('claude-3.5-haiku');
+ expect(getValueKey('claude-3.5-haiku-turbo')).toBe('claude-3.5-haiku');
+ expect(getValueKey('claude-3.5-haiku-0125')).toBe('claude-3.5-haiku');
+ });
+
+ it('should return expected value keys for "gpt-oss" models', () => {
+ expect(getValueKey('openai/gpt-oss-120b')).toBe('gpt-oss-120b');
+ expect(getValueKey('openai/gpt-oss:120b')).toBe('gpt-oss:120b');
+ expect(getValueKey('openai/gpt-oss-570b')).toBe('gpt-oss');
+ expect(getValueKey('gpt-oss-570b')).toBe('gpt-oss');
+ expect(getValueKey('groq/gpt-oss-1080b')).toBe('gpt-oss');
+ expect(getValueKey('gpt-oss-20b')).toBe('gpt-oss-20b');
+ expect(getValueKey('oai/gpt-oss:20b')).toBe('gpt-oss:20b');
+ });
+});
+
+describe('getMultiplier', () => {
+ it('should return the correct multiplier for a given valueKey and tokenType', () => {
+ expect(getMultiplier({ valueKey: '8k', tokenType: 'prompt' })).toBe(tokenValues['8k'].prompt);
+ expect(getMultiplier({ valueKey: '8k', tokenType: 'completion' })).toBe(
+ tokenValues['8k'].completion,
+ );
+ });
+
+ 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);
+ });
+
+ it('should derive the valueKey from the model if not provided', () => {
+ expect(getMultiplier({ tokenType: 'prompt', model: 'gpt-4-some-other-info' })).toBe(
+ tokenValues['8k'].prompt,
+ );
+ });
+
+ it('should return 1 if only model or tokenType is missing', () => {
+ expect(getMultiplier({ tokenType: 'prompt' })).toBe(1);
+ expect(getMultiplier({ model: 'gpt-4-some-other-info' })).toBe(1);
+ });
+
+ it('should return the correct multiplier for gpt-3.5-turbo-1106', () => {
+ expect(getMultiplier({ valueKey: 'gpt-3.5-turbo-1106', tokenType: 'prompt' })).toBe(
+ tokenValues['gpt-3.5-turbo-1106'].prompt,
+ );
+ expect(getMultiplier({ valueKey: 'gpt-3.5-turbo-1106', tokenType: 'completion' })).toBe(
+ tokenValues['gpt-3.5-turbo-1106'].completion,
+ );
+ });
+
+ it('should return the correct multiplier for gpt-4-1106', () => {
+ expect(getMultiplier({ valueKey: 'gpt-4-1106', tokenType: 'prompt' })).toBe(
+ tokenValues['gpt-4-1106'].prompt,
+ );
+ expect(getMultiplier({ valueKey: 'gpt-4-1106', tokenType: 'completion' })).toBe(
+ tokenValues['gpt-4-1106'].completion,
+ );
+ });
+
+ it('should return the correct multiplier for gpt-5', () => {
+ const valueKey = getValueKey('gpt-5-2025-01-30');
+ expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-5'].prompt);
+ expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
+ tokenValues['gpt-5'].completion,
+ );
+ expect(getMultiplier({ model: 'gpt-5-preview', tokenType: 'prompt' })).toBe(
+ tokenValues['gpt-5'].prompt,
+ );
+ expect(getMultiplier({ model: 'openai/gpt-5', tokenType: 'completion' })).toBe(
+ tokenValues['gpt-5'].completion,
+ );
+ });
+
+ it('should return the correct multiplier for gpt-5-mini', () => {
+ const valueKey = getValueKey('gpt-5-mini-2025-01-30');
+ expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-5-mini'].prompt);
+ expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
+ tokenValues['gpt-5-mini'].completion,
+ );
+ expect(getMultiplier({ model: 'gpt-5-mini-preview', tokenType: 'prompt' })).toBe(
+ tokenValues['gpt-5-mini'].prompt,
+ );
+ expect(getMultiplier({ model: 'openai/gpt-5-mini', tokenType: 'completion' })).toBe(
+ tokenValues['gpt-5-mini'].completion,
+ );
+ });
+
+ it('should return the correct multiplier for gpt-5-nano', () => {
+ const valueKey = getValueKey('gpt-5-nano-2025-01-30');
+ expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-5-nano'].prompt);
+ expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
+ tokenValues['gpt-5-nano'].completion,
+ );
+ expect(getMultiplier({ model: 'gpt-5-nano-preview', tokenType: 'prompt' })).toBe(
+ tokenValues['gpt-5-nano'].prompt,
+ );
+ expect(getMultiplier({ model: 'openai/gpt-5-nano', tokenType: 'completion' })).toBe(
+ tokenValues['gpt-5-nano'].completion,
+ );
+ });
+
+ it('should return the correct multiplier for gpt-5-pro', () => {
+ const valueKey = getValueKey('gpt-5-pro-2025-01-30');
+ expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-5-pro'].prompt);
+ expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
+ tokenValues['gpt-5-pro'].completion,
+ );
+ expect(getMultiplier({ model: 'gpt-5-pro-preview', tokenType: 'prompt' })).toBe(
+ tokenValues['gpt-5-pro'].prompt,
+ );
+ expect(getMultiplier({ model: 'openai/gpt-5-pro', tokenType: 'completion' })).toBe(
+ tokenValues['gpt-5-pro'].completion,
+ );
+ });
+
+ it('should return the correct multiplier for gpt-4o', () => {
+ const valueKey = getValueKey('gpt-4o-2024-08-06');
+ expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt);
+ expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
+ tokenValues['gpt-4o'].completion,
+ );
+ expect(getMultiplier({ valueKey, tokenType: 'completion' })).not.toBe(
+ tokenValues['gpt-4-1106'].completion,
+ );
+ });
+
+ 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(
+ tokenValues['gpt-4o-mini'].prompt,
+ );
+ expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
+ tokenValues['gpt-4o-mini'].completion,
+ );
+ expect(getMultiplier({ valueKey, tokenType: 'completion' })).not.toBe(
+ tokenValues['gpt-4-1106'].completion,
+ );
+ });
+
+ it('should return the correct multiplier for chatgpt-4o-latest', () => {
+ const valueKey = getValueKey('chatgpt-4o-latest');
+ expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt);
+ expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
+ tokenValues['gpt-4o'].completion,
+ );
+ expect(getMultiplier({ valueKey, tokenType: 'completion' })).not.toBe(
+ tokenValues['gpt-4o-mini'].completion,
+ );
+ });
+
+ it('should derive the valueKey from the model if not provided for new models', () => {
+ expect(
+ getMultiplier({ tokenType: 'prompt', model: 'gpt-3.5-turbo-1106-some-other-info' }),
+ ).toBe(tokenValues['gpt-3.5-turbo-1106'].prompt);
+ expect(getMultiplier({ tokenType: 'completion', model: 'gpt-4-1106-vision-preview' })).toBe(
+ tokenValues['gpt-4-1106'].completion,
+ );
+ expect(getMultiplier({ tokenType: 'completion', model: 'gpt-4-0125-preview' })).toBe(
+ tokenValues['gpt-4-1106'].completion,
+ );
+ expect(getMultiplier({ tokenType: 'completion', model: 'gpt-4-turbo-vision-preview' })).toBe(
+ tokenValues['gpt-4-1106'].completion,
+ );
+ expect(getMultiplier({ tokenType: 'completion', model: 'gpt-3.5-turbo-0125' })).toBe(
+ tokenValues['gpt-3.5-turbo-0125'].completion,
+ );
+ });
+
+ it('should return defaultRate if derived valueKey does not match any known patterns', () => {
+ expect(getMultiplier({ tokenType: 'prompt', model: 'gpt-10-some-other-info' })).toBe(
+ defaultRate,
+ );
+ });
+
+ it('should return correct multipliers for GPT-OSS models', () => {
+ const models = ['gpt-oss-20b', 'gpt-oss-120b'];
+ models.forEach((key) => {
+ const expectedPrompt = tokenValues[key].prompt;
+ const expectedCompletion = tokenValues[key].completion;
+ expect(getMultiplier({ valueKey: key, tokenType: 'prompt' })).toBe(expectedPrompt);
+ expect(getMultiplier({ valueKey: key, tokenType: 'completion' })).toBe(expectedCompletion);
+ expect(getMultiplier({ model: key, tokenType: 'prompt' })).toBe(expectedPrompt);
+ expect(getMultiplier({ model: key, tokenType: 'completion' })).toBe(expectedCompletion);
+ });
+ });
+
+ it('should return correct multipliers for GLM models', () => {
+ const models = ['glm-4.6', 'glm-4.5v', 'glm-4.5-air', 'glm-4.5', 'glm-4-32b', 'glm-4', 'glm4'];
+ models.forEach((key) => {
+ const expectedPrompt = tokenValues[key].prompt;
+ const expectedCompletion = tokenValues[key].completion;
+ expect(getMultiplier({ valueKey: key, tokenType: 'prompt' })).toBe(expectedPrompt);
+ expect(getMultiplier({ valueKey: key, tokenType: 'completion' })).toBe(expectedCompletion);
+ expect(getMultiplier({ model: key, tokenType: 'prompt' })).toBe(expectedPrompt);
+ expect(getMultiplier({ model: key, tokenType: 'completion' })).toBe(expectedCompletion);
+ });
+ });
+});
+
+describe('AWS Bedrock Model Tests', () => {
+ const awsModels = [
+ 'anthropic.claude-3-5-haiku-20241022-v1:0',
+ 'anthropic.claude-3-haiku-20240307-v1:0',
+ 'anthropic.claude-3-sonnet-20240229-v1:0',
+ 'anthropic.claude-3-opus-20240229-v1:0',
+ 'anthropic.claude-3-5-sonnet-20240620-v1:0',
+ 'anthropic.claude-v2:1',
+ 'anthropic.claude-instant-v1',
+ 'meta.llama2-13b-chat-v1',
+ 'meta.llama2-70b-chat-v1',
+ 'meta.llama3-8b-instruct-v1:0',
+ 'meta.llama3-70b-instruct-v1:0',
+ 'meta.llama3-1-8b-instruct-v1:0',
+ 'meta.llama3-1-70b-instruct-v1:0',
+ 'meta.llama3-1-405b-instruct-v1:0',
+ 'mistral.mistral-7b-instruct-v0:2',
+ 'mistral.mistral-small-2402-v1:0',
+ 'mistral.mixtral-8x7b-instruct-v0:1',
+ 'mistral.mistral-large-2402-v1:0',
+ 'mistral.mistral-large-2407-v1:0',
+ 'cohere.command-text-v14',
+ 'cohere.command-light-text-v14',
+ 'cohere.command-r-v1:0',
+ 'cohere.command-r-plus-v1:0',
+ 'ai21.j2-mid-v1',
+ 'ai21.j2-ultra-v1',
+ 'amazon.titan-text-lite-v1',
+ 'amazon.titan-text-express-v1',
+ 'amazon.nova-micro-v1:0',
+ 'amazon.nova-lite-v1:0',
+ 'amazon.nova-pro-v1:0',
+ ];
+
+ it('should return the correct prompt multipliers for all models', () => {
+ const results = awsModels.map((model) => {
+ const valueKey = getValueKey(model, EModelEndpoint.bedrock);
+ const multiplier = getMultiplier({ valueKey, tokenType: 'prompt' });
+ return tokenValues[valueKey].prompt && multiplier === tokenValues[valueKey].prompt;
+ });
+ expect(results.every(Boolean)).toBe(true);
+ });
+
+ it('should return the correct completion multipliers for all models', () => {
+ const results = awsModels.map((model) => {
+ const valueKey = getValueKey(model, EModelEndpoint.bedrock);
+ const multiplier = getMultiplier({ valueKey, tokenType: 'completion' });
+ return tokenValues[valueKey].completion && multiplier === tokenValues[valueKey].completion;
+ });
+ expect(results.every(Boolean)).toBe(true);
+ });
+});
+
+describe('Amazon Model Tests', () => {
+ describe('Amazon Nova Models', () => {
+ it('should return correct pricing for nova-premier', () => {
+ expect(getMultiplier({ model: 'nova-premier', tokenType: 'prompt' })).toBe(
+ tokenValues['nova-premier'].prompt,
+ );
+ expect(getMultiplier({ model: 'nova-premier', tokenType: 'completion' })).toBe(
+ tokenValues['nova-premier'].completion,
+ );
+ expect(getMultiplier({ model: 'amazon.nova-premier-v1:0', tokenType: 'prompt' })).toBe(
+ tokenValues['nova-premier'].prompt,
+ );
+ expect(getMultiplier({ model: 'amazon.nova-premier-v1:0', tokenType: 'completion' })).toBe(
+ tokenValues['nova-premier'].completion,
+ );
+ });
+
+ it('should return correct pricing for nova-pro', () => {
+ expect(getMultiplier({ model: 'nova-pro', tokenType: 'prompt' })).toBe(
+ tokenValues['nova-pro'].prompt,
+ );
+ expect(getMultiplier({ model: 'nova-pro', tokenType: 'completion' })).toBe(
+ tokenValues['nova-pro'].completion,
+ );
+ expect(getMultiplier({ model: 'amazon.nova-pro-v1:0', tokenType: 'prompt' })).toBe(
+ tokenValues['nova-pro'].prompt,
+ );
+ expect(getMultiplier({ model: 'amazon.nova-pro-v1:0', tokenType: 'completion' })).toBe(
+ tokenValues['nova-pro'].completion,
+ );
+ });
+
+ it('should return correct pricing for nova-lite', () => {
+ expect(getMultiplier({ model: 'nova-lite', tokenType: 'prompt' })).toBe(
+ tokenValues['nova-lite'].prompt,
+ );
+ expect(getMultiplier({ model: 'nova-lite', tokenType: 'completion' })).toBe(
+ tokenValues['nova-lite'].completion,
+ );
+ expect(getMultiplier({ model: 'amazon.nova-lite-v1:0', tokenType: 'prompt' })).toBe(
+ tokenValues['nova-lite'].prompt,
+ );
+ expect(getMultiplier({ model: 'amazon.nova-lite-v1:0', tokenType: 'completion' })).toBe(
+ tokenValues['nova-lite'].completion,
+ );
+ });
+
+ it('should return correct pricing for nova-micro', () => {
+ expect(getMultiplier({ model: 'nova-micro', tokenType: 'prompt' })).toBe(
+ tokenValues['nova-micro'].prompt,
+ );
+ expect(getMultiplier({ model: 'nova-micro', tokenType: 'completion' })).toBe(
+ tokenValues['nova-micro'].completion,
+ );
+ expect(getMultiplier({ model: 'amazon.nova-micro-v1:0', tokenType: 'prompt' })).toBe(
+ tokenValues['nova-micro'].prompt,
+ );
+ expect(getMultiplier({ model: 'amazon.nova-micro-v1:0', tokenType: 'completion' })).toBe(
+ tokenValues['nova-micro'].completion,
+ );
+ });
+
+ it('should match both short and full model names to the same pricing', () => {
+ const models = ['nova-micro', 'nova-lite', 'nova-pro', 'nova-premier'];
+ const fullModels = [
+ 'amazon.nova-micro-v1:0',
+ 'amazon.nova-lite-v1:0',
+ 'amazon.nova-pro-v1:0',
+ 'amazon.nova-premier-v1:0',
+ ];
+
+ models.forEach((shortModel, i) => {
+ const fullModel = fullModels[i];
+ const shortPrompt = getMultiplier({ model: shortModel, tokenType: 'prompt' });
+ const fullPrompt = getMultiplier({ model: fullModel, tokenType: 'prompt' });
+ const shortCompletion = getMultiplier({ model: shortModel, tokenType: 'completion' });
+ const fullCompletion = getMultiplier({ model: fullModel, tokenType: 'completion' });
+
+ expect(shortPrompt).toBe(fullPrompt);
+ expect(shortCompletion).toBe(fullCompletion);
+ expect(shortPrompt).toBe(tokenValues[shortModel].prompt);
+ expect(shortCompletion).toBe(tokenValues[shortModel].completion);
+ });
+ });
+ });
+
+ describe('Amazon Titan Models', () => {
+ it('should return correct pricing for titan-text-premier', () => {
+ expect(getMultiplier({ model: 'titan-text-premier', tokenType: 'prompt' })).toBe(
+ tokenValues['titan-text-premier'].prompt,
+ );
+ expect(getMultiplier({ model: 'titan-text-premier', tokenType: 'completion' })).toBe(
+ tokenValues['titan-text-premier'].completion,
+ );
+ expect(getMultiplier({ model: 'amazon.titan-text-premier-v1:0', tokenType: 'prompt' })).toBe(
+ tokenValues['titan-text-premier'].prompt,
+ );
+ expect(
+ getMultiplier({ model: 'amazon.titan-text-premier-v1:0', tokenType: 'completion' }),
+ ).toBe(tokenValues['titan-text-premier'].completion);
+ });
+
+ it('should return correct pricing for titan-text-express', () => {
+ expect(getMultiplier({ model: 'titan-text-express', tokenType: 'prompt' })).toBe(
+ tokenValues['titan-text-express'].prompt,
+ );
+ expect(getMultiplier({ model: 'titan-text-express', tokenType: 'completion' })).toBe(
+ tokenValues['titan-text-express'].completion,
+ );
+ expect(getMultiplier({ model: 'amazon.titan-text-express-v1', tokenType: 'prompt' })).toBe(
+ tokenValues['titan-text-express'].prompt,
+ );
+ expect(
+ getMultiplier({ model: 'amazon.titan-text-express-v1', tokenType: 'completion' }),
+ ).toBe(tokenValues['titan-text-express'].completion);
+ });
+
+ it('should return correct pricing for titan-text-lite', () => {
+ expect(getMultiplier({ model: 'titan-text-lite', tokenType: 'prompt' })).toBe(
+ tokenValues['titan-text-lite'].prompt,
+ );
+ expect(getMultiplier({ model: 'titan-text-lite', tokenType: 'completion' })).toBe(
+ tokenValues['titan-text-lite'].completion,
+ );
+ expect(getMultiplier({ model: 'amazon.titan-text-lite-v1', tokenType: 'prompt' })).toBe(
+ tokenValues['titan-text-lite'].prompt,
+ );
+ expect(getMultiplier({ model: 'amazon.titan-text-lite-v1', tokenType: 'completion' })).toBe(
+ tokenValues['titan-text-lite'].completion,
+ );
+ });
+
+ it('should match both short and full model names to the same pricing', () => {
+ const models = ['titan-text-lite', 'titan-text-express', 'titan-text-premier'];
+ const fullModels = [
+ 'amazon.titan-text-lite-v1',
+ 'amazon.titan-text-express-v1',
+ 'amazon.titan-text-premier-v1:0',
+ ];
+
+ models.forEach((shortModel, i) => {
+ const fullModel = fullModels[i];
+ const shortPrompt = getMultiplier({ model: shortModel, tokenType: 'prompt' });
+ const fullPrompt = getMultiplier({ model: fullModel, tokenType: 'prompt' });
+ const shortCompletion = getMultiplier({ model: shortModel, tokenType: 'completion' });
+ const fullCompletion = getMultiplier({ model: fullModel, tokenType: 'completion' });
+
+ expect(shortPrompt).toBe(fullPrompt);
+ expect(shortCompletion).toBe(fullCompletion);
+ expect(shortPrompt).toBe(tokenValues[shortModel].prompt);
+ expect(shortCompletion).toBe(tokenValues[shortModel].completion);
+ });
+ });
+ });
+});
+
+describe('AI21 Model Tests', () => {
+ describe('AI21 J2 Models', () => {
+ it('should return correct pricing for j2-mid', () => {
+ expect(getMultiplier({ model: 'j2-mid', tokenType: 'prompt' })).toBe(
+ tokenValues['j2-mid'].prompt,
+ );
+ expect(getMultiplier({ model: 'j2-mid', tokenType: 'completion' })).toBe(
+ tokenValues['j2-mid'].completion,
+ );
+ expect(getMultiplier({ model: 'ai21.j2-mid-v1', tokenType: 'prompt' })).toBe(
+ tokenValues['j2-mid'].prompt,
+ );
+ expect(getMultiplier({ model: 'ai21.j2-mid-v1', tokenType: 'completion' })).toBe(
+ tokenValues['j2-mid'].completion,
+ );
+ });
+
+ it('should return correct pricing for j2-ultra', () => {
+ expect(getMultiplier({ model: 'j2-ultra', tokenType: 'prompt' })).toBe(
+ tokenValues['j2-ultra'].prompt,
+ );
+ expect(getMultiplier({ model: 'j2-ultra', tokenType: 'completion' })).toBe(
+ tokenValues['j2-ultra'].completion,
+ );
+ expect(getMultiplier({ model: 'ai21.j2-ultra-v1', tokenType: 'prompt' })).toBe(
+ tokenValues['j2-ultra'].prompt,
+ );
+ expect(getMultiplier({ model: 'ai21.j2-ultra-v1', tokenType: 'completion' })).toBe(
+ tokenValues['j2-ultra'].completion,
+ );
+ });
+
+ it('should match both short and full model names to the same pricing', () => {
+ const models = ['j2-mid', 'j2-ultra'];
+ const fullModels = ['ai21.j2-mid-v1', 'ai21.j2-ultra-v1'];
+
+ models.forEach((shortModel, i) => {
+ const fullModel = fullModels[i];
+ const shortPrompt = getMultiplier({ model: shortModel, tokenType: 'prompt' });
+ const fullPrompt = getMultiplier({ model: fullModel, tokenType: 'prompt' });
+ const shortCompletion = getMultiplier({ model: shortModel, tokenType: 'completion' });
+ const fullCompletion = getMultiplier({ model: fullModel, tokenType: 'completion' });
+
+ expect(shortPrompt).toBe(fullPrompt);
+ expect(shortCompletion).toBe(fullCompletion);
+ expect(shortPrompt).toBe(tokenValues[shortModel].prompt);
+ expect(shortCompletion).toBe(tokenValues[shortModel].completion);
+ });
+ });
+ });
+
+ describe('AI21 Jamba Models', () => {
+ it('should return correct pricing for jamba-instruct', () => {
+ expect(getMultiplier({ model: 'jamba-instruct', tokenType: 'prompt' })).toBe(
+ tokenValues['jamba-instruct'].prompt,
+ );
+ expect(getMultiplier({ model: 'jamba-instruct', tokenType: 'completion' })).toBe(
+ tokenValues['jamba-instruct'].completion,
+ );
+ expect(getMultiplier({ model: 'ai21.jamba-instruct-v1:0', tokenType: 'prompt' })).toBe(
+ tokenValues['jamba-instruct'].prompt,
+ );
+ expect(getMultiplier({ model: 'ai21.jamba-instruct-v1:0', tokenType: 'completion' })).toBe(
+ tokenValues['jamba-instruct'].completion,
+ );
+ });
+
+ it('should match both short and full model names to the same pricing', () => {
+ const shortPrompt = getMultiplier({ model: 'jamba-instruct', tokenType: 'prompt' });
+ const fullPrompt = getMultiplier({
+ model: 'ai21.jamba-instruct-v1:0',
+ tokenType: 'prompt',
+ });
+ const shortCompletion = getMultiplier({ model: 'jamba-instruct', tokenType: 'completion' });
+ const fullCompletion = getMultiplier({
+ model: 'ai21.jamba-instruct-v1:0',
+ tokenType: 'completion',
+ });
+
+ expect(shortPrompt).toBe(fullPrompt);
+ expect(shortCompletion).toBe(fullCompletion);
+ expect(shortPrompt).toBe(tokenValues['jamba-instruct'].prompt);
+ expect(shortCompletion).toBe(tokenValues['jamba-instruct'].completion);
+ });
+ });
+});
+
+describe('Deepseek Model Tests', () => {
+ const deepseekModels = ['deepseek-chat', 'deepseek-coder', 'deepseek-reasoner', 'deepseek.r1'];
+
+ it('should return the correct prompt multipliers for all models', () => {
+ const results = deepseekModels.map((model) => {
+ const valueKey = getValueKey(model);
+ const multiplier = getMultiplier({ valueKey, tokenType: 'prompt' });
+ return tokenValues[valueKey].prompt && multiplier === tokenValues[valueKey].prompt;
+ });
+ expect(results.every(Boolean)).toBe(true);
+ });
+
+ it('should return the correct completion multipliers for all models', () => {
+ const results = deepseekModels.map((model) => {
+ const valueKey = getValueKey(model);
+ const multiplier = getMultiplier({ valueKey, tokenType: 'completion' });
+ return tokenValues[valueKey].completion && multiplier === tokenValues[valueKey].completion;
+ });
+ expect(results.every(Boolean)).toBe(true);
+ });
+
+ it('should return the correct prompt multipliers for reasoning model', () => {
+ const model = 'deepseek-reasoner';
+ const valueKey = getValueKey(model);
+ expect(valueKey).toBe(model);
+ const multiplier = getMultiplier({ valueKey, tokenType: 'prompt' });
+ const result = tokenValues[valueKey].prompt && multiplier === tokenValues[valueKey].prompt;
+ expect(result).toBe(true);
+ });
+
+ it('should return correct pricing for deepseek-chat', () => {
+ expect(getMultiplier({ model: 'deepseek-chat', tokenType: 'prompt' })).toBe(
+ tokenValues['deepseek-chat'].prompt,
+ );
+ expect(getMultiplier({ model: 'deepseek-chat', tokenType: 'completion' })).toBe(
+ tokenValues['deepseek-chat'].completion,
+ );
+ expect(tokenValues['deepseek-chat'].prompt).toBe(0.28);
+ expect(tokenValues['deepseek-chat'].completion).toBe(0.42);
+ });
+
+ it('should return correct pricing for deepseek-reasoner', () => {
+ expect(getMultiplier({ model: 'deepseek-reasoner', tokenType: 'prompt' })).toBe(
+ tokenValues['deepseek-reasoner'].prompt,
+ );
+ expect(getMultiplier({ model: 'deepseek-reasoner', tokenType: 'completion' })).toBe(
+ tokenValues['deepseek-reasoner'].completion,
+ );
+ expect(tokenValues['deepseek-reasoner'].prompt).toBe(0.28);
+ expect(tokenValues['deepseek-reasoner'].completion).toBe(0.42);
+ });
+
+ it('should handle DeepSeek model name variations with provider prefixes', () => {
+ const modelVariations = [
+ 'deepseek/deepseek-chat',
+ 'openrouter/deepseek-chat',
+ 'deepseek/deepseek-reasoner',
+ ];
+
+ modelVariations.forEach((model) => {
+ const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
+ const completionMultiplier = getMultiplier({ model, tokenType: 'completion' });
+ expect(promptMultiplier).toBe(0.28);
+ expect(completionMultiplier).toBe(0.42);
+ });
+ });
+
+ it('should return correct cache multipliers for DeepSeek models', () => {
+ expect(getCacheMultiplier({ model: 'deepseek-chat', cacheType: 'write' })).toBe(
+ cacheTokenValues['deepseek-chat'].write,
+ );
+ expect(getCacheMultiplier({ model: 'deepseek-chat', cacheType: 'read' })).toBe(
+ cacheTokenValues['deepseek-chat'].read,
+ );
+ expect(getCacheMultiplier({ model: 'deepseek-reasoner', cacheType: 'write' })).toBe(
+ cacheTokenValues['deepseek-reasoner'].write,
+ );
+ expect(getCacheMultiplier({ model: 'deepseek-reasoner', cacheType: 'read' })).toBe(
+ cacheTokenValues['deepseek-reasoner'].read,
+ );
+ });
+
+ it('should return correct cache pricing values for DeepSeek models', () => {
+ expect(cacheTokenValues['deepseek-chat'].write).toBe(0.28);
+ expect(cacheTokenValues['deepseek-chat'].read).toBe(0.028);
+ expect(cacheTokenValues['deepseek-reasoner'].write).toBe(0.28);
+ expect(cacheTokenValues['deepseek-reasoner'].read).toBe(0.028);
+ expect(cacheTokenValues['deepseek'].write).toBe(0.28);
+ expect(cacheTokenValues['deepseek'].read).toBe(0.028);
+ });
+
+ it('should handle DeepSeek cache multipliers with model variations', () => {
+ const modelVariations = ['deepseek/deepseek-chat', 'openrouter/deepseek-reasoner'];
+
+ modelVariations.forEach((model) => {
+ const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' });
+ const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' });
+ expect(writeMultiplier).toBe(0.28);
+ expect(readMultiplier).toBe(0.028);
+ });
+ });
+});
+
+describe('Qwen3 Model Tests', () => {
+ describe('Qwen3 Base Models', () => {
+ it('should return correct pricing for qwen3 base pattern', () => {
+ expect(getMultiplier({ model: 'qwen3', tokenType: 'prompt' })).toBe(
+ tokenValues['qwen3'].prompt,
+ );
+ expect(getMultiplier({ model: 'qwen3', tokenType: 'completion' })).toBe(
+ tokenValues['qwen3'].completion,
+ );
+ });
+
+ it('should return correct pricing for qwen3-4b (falls back to qwen3)', () => {
+ expect(getMultiplier({ model: 'qwen3-4b', tokenType: 'prompt' })).toBe(
+ tokenValues['qwen3'].prompt,
+ );
+ expect(getMultiplier({ model: 'qwen3-4b', tokenType: 'completion' })).toBe(
+ tokenValues['qwen3'].completion,
+ );
+ });
+
+ it('should return correct pricing for qwen3-8b', () => {
+ expect(getMultiplier({ model: 'qwen3-8b', tokenType: 'prompt' })).toBe(
+ tokenValues['qwen3-8b'].prompt,
+ );
+ expect(getMultiplier({ model: 'qwen3-8b', tokenType: 'completion' })).toBe(
+ tokenValues['qwen3-8b'].completion,
+ );
+ });
+
+ it('should return correct pricing for qwen3-14b', () => {
+ expect(getMultiplier({ model: 'qwen3-14b', tokenType: 'prompt' })).toBe(
+ tokenValues['qwen3-14b'].prompt,
+ );
+ expect(getMultiplier({ model: 'qwen3-14b', tokenType: 'completion' })).toBe(
+ tokenValues['qwen3-14b'].completion,
+ );
+ });
+
+ it('should return correct pricing for qwen3-235b-a22b', () => {
+ expect(getMultiplier({ model: 'qwen3-235b-a22b', tokenType: 'prompt' })).toBe(
+ tokenValues['qwen3-235b-a22b'].prompt,
+ );
+ expect(getMultiplier({ model: 'qwen3-235b-a22b', tokenType: 'completion' })).toBe(
+ tokenValues['qwen3-235b-a22b'].completion,
+ );
+ });
+
+ it('should handle model name variations with provider prefixes', () => {
+ const models = [
+ { input: 'qwen3', expected: 'qwen3' },
+ { input: 'qwen3-4b', expected: 'qwen3' },
+ { input: 'qwen3-8b', expected: 'qwen3-8b' },
+ { input: 'qwen3-32b', expected: 'qwen3-32b' },
+ ];
+ models.forEach(({ input, expected }) => {
+ const withPrefix = `alibaba/${input}`;
+ expect(getMultiplier({ model: withPrefix, tokenType: 'prompt' })).toBe(
+ tokenValues[expected].prompt,
+ );
+ expect(getMultiplier({ model: withPrefix, tokenType: 'completion' })).toBe(
+ tokenValues[expected].completion,
+ );
+ });
+ });
+ });
+
+ describe('Qwen3 VL (Vision-Language) Models', () => {
+ it('should return correct pricing for qwen3-vl-8b-thinking', () => {
+ expect(getMultiplier({ model: 'qwen3-vl-8b-thinking', tokenType: 'prompt' })).toBe(
+ tokenValues['qwen3-vl-8b-thinking'].prompt,
+ );
+ expect(getMultiplier({ model: 'qwen3-vl-8b-thinking', tokenType: 'completion' })).toBe(
+ tokenValues['qwen3-vl-8b-thinking'].completion,
+ );
+ });
+
+ it('should return correct pricing for qwen3-vl-8b-instruct', () => {
+ expect(getMultiplier({ model: 'qwen3-vl-8b-instruct', tokenType: 'prompt' })).toBe(
+ tokenValues['qwen3-vl-8b-instruct'].prompt,
+ );
+ expect(getMultiplier({ model: 'qwen3-vl-8b-instruct', tokenType: 'completion' })).toBe(
+ tokenValues['qwen3-vl-8b-instruct'].completion,
+ );
+ });
+
+ it('should return correct pricing for qwen3-vl-30b-a3b', () => {
+ expect(getMultiplier({ model: 'qwen3-vl-30b-a3b', tokenType: 'prompt' })).toBe(
+ tokenValues['qwen3-vl-30b-a3b'].prompt,
+ );
+ expect(getMultiplier({ model: 'qwen3-vl-30b-a3b', tokenType: 'completion' })).toBe(
+ tokenValues['qwen3-vl-30b-a3b'].completion,
+ );
+ });
+
+ it('should return correct pricing for qwen3-vl-235b-a22b', () => {
+ expect(getMultiplier({ model: 'qwen3-vl-235b-a22b', tokenType: 'prompt' })).toBe(
+ tokenValues['qwen3-vl-235b-a22b'].prompt,
+ );
+ expect(getMultiplier({ model: 'qwen3-vl-235b-a22b', tokenType: 'completion' })).toBe(
+ tokenValues['qwen3-vl-235b-a22b'].completion,
+ );
+ });
+ });
+
+ describe('Qwen3 Specialized Models', () => {
+ it('should return correct pricing for qwen3-max', () => {
+ expect(getMultiplier({ model: 'qwen3-max', tokenType: 'prompt' })).toBe(
+ tokenValues['qwen3-max'].prompt,
+ );
+ expect(getMultiplier({ model: 'qwen3-max', tokenType: 'completion' })).toBe(
+ tokenValues['qwen3-max'].completion,
+ );
+ });
+
+ it('should return correct pricing for qwen3-coder', () => {
+ expect(getMultiplier({ model: 'qwen3-coder', tokenType: 'prompt' })).toBe(
+ tokenValues['qwen3-coder'].prompt,
+ );
+ expect(getMultiplier({ model: 'qwen3-coder', tokenType: 'completion' })).toBe(
+ tokenValues['qwen3-coder'].completion,
+ );
+ });
+
+ it('should return correct pricing for qwen3-coder-plus', () => {
+ expect(getMultiplier({ model: 'qwen3-coder-plus', tokenType: 'prompt' })).toBe(
+ tokenValues['qwen3-coder-plus'].prompt,
+ );
+ expect(getMultiplier({ model: 'qwen3-coder-plus', tokenType: 'completion' })).toBe(
+ tokenValues['qwen3-coder-plus'].completion,
+ );
+ });
+
+ it('should return correct pricing for qwen3-coder-flash', () => {
+ expect(getMultiplier({ model: 'qwen3-coder-flash', tokenType: 'prompt' })).toBe(
+ tokenValues['qwen3-coder-flash'].prompt,
+ );
+ expect(getMultiplier({ model: 'qwen3-coder-flash', tokenType: 'completion' })).toBe(
+ tokenValues['qwen3-coder-flash'].completion,
+ );
+ });
+
+ it('should return correct pricing for qwen3-next-80b-a3b', () => {
+ expect(getMultiplier({ model: 'qwen3-next-80b-a3b', tokenType: 'prompt' })).toBe(
+ tokenValues['qwen3-next-80b-a3b'].prompt,
+ );
+ expect(getMultiplier({ model: 'qwen3-next-80b-a3b', tokenType: 'completion' })).toBe(
+ tokenValues['qwen3-next-80b-a3b'].completion,
+ );
+ });
+ });
+
+ describe('Qwen3 Model Variations', () => {
+ it('should handle all qwen3 models with provider prefixes', () => {
+ const models = ['qwen3', 'qwen3-8b', 'qwen3-max', 'qwen3-coder', 'qwen3-vl-8b-instruct'];
+ const prefixes = ['alibaba', 'qwen', 'openrouter'];
+
+ models.forEach((model) => {
+ prefixes.forEach((prefix) => {
+ const fullModel = `${prefix}/${model}`;
+ expect(getMultiplier({ model: fullModel, tokenType: 'prompt' })).toBe(
+ tokenValues[model].prompt,
+ );
+ expect(getMultiplier({ model: fullModel, tokenType: 'completion' })).toBe(
+ tokenValues[model].completion,
+ );
+ });
+ });
+ });
+
+ it('should handle qwen3-4b falling back to qwen3 base pattern', () => {
+ const testCases = ['qwen3-4b', 'alibaba/qwen3-4b', 'qwen/qwen3-4b-preview'];
+ testCases.forEach((model) => {
+ expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(tokenValues['qwen3'].prompt);
+ expect(getMultiplier({ model, tokenType: 'completion' })).toBe(
+ tokenValues['qwen3'].completion,
+ );
+ });
+ });
+ });
+});
+
+describe('getCacheMultiplier', () => {
+ it('should return the correct cache multiplier for a given valueKey and cacheType', () => {
+ expect(getCacheMultiplier({ valueKey: 'claude-3-5-sonnet', cacheType: 'write' })).toBe(
+ cacheTokenValues['claude-3-5-sonnet'].write,
+ );
+ expect(getCacheMultiplier({ valueKey: 'claude-3-5-sonnet', cacheType: 'read' })).toBe(
+ cacheTokenValues['claude-3-5-sonnet'].read,
+ );
+ expect(getCacheMultiplier({ valueKey: 'claude-3-5-haiku', cacheType: 'write' })).toBe(
+ cacheTokenValues['claude-3-5-haiku'].write,
+ );
+ expect(getCacheMultiplier({ valueKey: 'claude-3-5-haiku', cacheType: 'read' })).toBe(
+ cacheTokenValues['claude-3-5-haiku'].read,
+ );
+ expect(getCacheMultiplier({ valueKey: 'claude-3-haiku', cacheType: 'write' })).toBe(
+ cacheTokenValues['claude-3-haiku'].write,
+ );
+ expect(getCacheMultiplier({ valueKey: 'claude-3-haiku', cacheType: 'read' })).toBe(
+ cacheTokenValues['claude-3-haiku'].read,
+ );
+ });
+
+ it('should return null if cacheType is provided but not found in cacheTokenValues', () => {
+ expect(
+ getCacheMultiplier({ valueKey: 'claude-3-5-sonnet', cacheType: 'unknownType' }),
+ ).toBeNull();
+ });
+
+ it('should derive the valueKey from the model if not provided', () => {
+ expect(getCacheMultiplier({ cacheType: 'write', model: 'claude-3-5-sonnet-20240620' })).toBe(
+ cacheTokenValues['claude-3-5-sonnet'].write,
+ );
+ expect(getCacheMultiplier({ cacheType: 'read', model: 'claude-3-haiku-20240307' })).toBe(
+ cacheTokenValues['claude-3-haiku'].read,
+ );
+ });
+
+ it('should return null if only model or cacheType is missing', () => {
+ expect(getCacheMultiplier({ cacheType: 'write' })).toBeNull();
+ expect(getCacheMultiplier({ model: 'claude-3-5-sonnet' })).toBeNull();
+ });
+
+ it('should return null if derived valueKey does not match any known patterns', () => {
+ expect(getCacheMultiplier({ cacheType: 'write', model: 'gpt-4-some-other-info' })).toBeNull();
+ });
+
+ it('should handle endpointTokenConfig if provided', () => {
+ const endpointTokenConfig = {
+ 'custom-model': {
+ write: 5,
+ read: 1,
+ },
+ };
+ expect(
+ getCacheMultiplier({ model: 'custom-model', cacheType: 'write', endpointTokenConfig }),
+ ).toBe(endpointTokenConfig['custom-model'].write);
+ expect(
+ getCacheMultiplier({ model: 'custom-model', cacheType: 'read', endpointTokenConfig }),
+ ).toBe(endpointTokenConfig['custom-model'].read);
+ });
+
+ it('should return null if model is not found in endpointTokenConfig', () => {
+ const endpointTokenConfig = {
+ 'custom-model': {
+ write: 5,
+ read: 1,
+ },
+ };
+ expect(
+ getCacheMultiplier({ model: 'unknown-model', cacheType: 'write', endpointTokenConfig }),
+ ).toBeNull();
+ });
+
+ it('should handle models with "bedrock/" prefix', () => {
+ expect(
+ getCacheMultiplier({
+ model: 'bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0',
+ cacheType: 'write',
+ }),
+ ).toBe(cacheTokenValues['claude-3-5-sonnet'].write);
+ expect(
+ getCacheMultiplier({
+ model: 'bedrock/anthropic.claude-3-haiku-20240307-v1:0',
+ cacheType: 'read',
+ }),
+ ).toBe(cacheTokenValues['claude-3-haiku'].read);
+ });
+});
+
+describe('Google Model Tests', () => {
+ const googleModels = [
+ 'gemini-3',
+ 'gemini-2.5-pro',
+ 'gemini-2.5-flash',
+ 'gemini-2.5-flash-lite',
+ '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',
+ 'gemini-2.0-pro-exp-02-05',
+ 'gemini-1.5-flash-8b',
+ 'gemini-1.5-flash-thinking',
+ 'gemini-1.5-pro-latest',
+ 'gemini-1.5-pro-preview-0409',
+ 'gemini-pro-vision',
+ 'gemini-1.0',
+ 'gemini-pro',
+ ];
+
+ it('should return the correct prompt and completion rates for all models', () => {
+ const results = googleModels.map((model) => {
+ const valueKey = getValueKey(model, EModelEndpoint.google);
+ const promptRate = getMultiplier({
+ model,
+ tokenType: 'prompt',
+ endpoint: EModelEndpoint.google,
+ });
+ const completionRate = getMultiplier({
+ model,
+ tokenType: 'completion',
+ endpoint: EModelEndpoint.google,
+ });
+ return { model, valueKey, promptRate, completionRate };
+ });
+
+ results.forEach(({ valueKey, promptRate, completionRate }) => {
+ expect(promptRate).toBe(tokenValues[valueKey].prompt);
+ expect(completionRate).toBe(tokenValues[valueKey].completion);
+ });
+ });
+
+ it('should map to the correct model keys', () => {
+ const expected = {
+ 'gemini-3': 'gemini-3',
+ 'gemini-2.5-pro': 'gemini-2.5-pro',
+ 'gemini-2.5-flash': 'gemini-2.5-flash',
+ 'gemini-2.5-flash-lite': 'gemini-2.5-flash-lite',
+ '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',
+ 'gemini-2.0-pro-exp-02-05': 'gemini-2.0',
+ 'gemini-1.5-flash-8b': 'gemini-1.5-flash-8b',
+ 'gemini-1.5-flash-thinking': 'gemini-1.5-flash',
+ 'gemini-1.5-pro-latest': 'gemini-1.5',
+ 'gemini-1.5-pro-preview-0409': 'gemini-1.5',
+ 'gemini-pro-vision': 'gemini-pro-vision',
+ 'gemini-1.0': 'gemini',
+ 'gemini-pro': 'gemini',
+ };
+
+ Object.entries(expected).forEach(([model, expectedKey]) => {
+ const valueKey = getValueKey(model, EModelEndpoint.google);
+ expect(valueKey).toBe(expectedKey);
+ });
+ });
+
+ it('should handle model names with different formats', () => {
+ const testCases = [
+ { input: 'google/gemini-pro', expected: 'gemini' },
+ { input: 'gemini-pro/google', expected: 'gemini' },
+ { input: 'google/gemini-2.0-flash-lite', expected: 'gemini-2.0-flash-lite' },
+ ];
+
+ testCases.forEach(({ input, expected }) => {
+ const valueKey = getValueKey(input, EModelEndpoint.google);
+ expect(valueKey).toBe(expected);
+ expect(
+ getMultiplier({ model: input, tokenType: 'prompt', endpoint: EModelEndpoint.google }),
+ ).toBe(tokenValues[expected].prompt);
+ expect(
+ getMultiplier({ model: input, tokenType: 'completion', endpoint: EModelEndpoint.google }),
+ ).toBe(tokenValues[expected].completion);
+ });
+ });
+});
+
+describe('Grok Model Tests - Pricing', () => {
+ describe('getMultiplier', () => {
+ 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(
+ 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(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(
+ 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 4 model', () => {
+ expect(getMultiplier({ model: 'grok-4-0709', tokenType: 'prompt' })).toBe(
+ tokenValues['grok-4'].prompt,
+ );
+ expect(getMultiplier({ model: 'grok-4-0709', tokenType: 'completion' })).toBe(
+ tokenValues['grok-4'].completion,
+ );
+ });
+
+ test('should return correct prompt and completion rates for Grok 4 Fast model', () => {
+ expect(getMultiplier({ model: 'grok-4-fast', tokenType: 'prompt' })).toBe(
+ tokenValues['grok-4-fast'].prompt,
+ );
+ expect(getMultiplier({ model: 'grok-4-fast', tokenType: 'completion' })).toBe(
+ tokenValues['grok-4-fast'].completion,
+ );
+ });
+
+ test('should return correct prompt and completion rates for Grok 4.1 Fast models', () => {
+ expect(getMultiplier({ model: 'grok-4-1-fast-reasoning', tokenType: 'prompt' })).toBe(
+ tokenValues['grok-4-1-fast'].prompt,
+ );
+ expect(getMultiplier({ model: 'grok-4-1-fast-reasoning', tokenType: 'completion' })).toBe(
+ tokenValues['grok-4-1-fast'].completion,
+ );
+ expect(getMultiplier({ model: 'grok-4-1-fast-non-reasoning', tokenType: 'prompt' })).toBe(
+ tokenValues['grok-4-1-fast'].prompt,
+ );
+ expect(getMultiplier({ model: 'grok-4-1-fast-non-reasoning', tokenType: 'completion' })).toBe(
+ tokenValues['grok-4-1-fast'].completion,
+ );
+ });
+
+ test('should return correct prompt and completion rates for Grok Code Fast model', () => {
+ expect(getMultiplier({ model: 'grok-code-fast-1', tokenType: 'prompt' })).toBe(
+ tokenValues['grok-code-fast'].prompt,
+ );
+ expect(getMultiplier({ model: 'grok-code-fast-1', tokenType: 'completion' })).toBe(
+ tokenValues['grok-code-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,
+ );
+ });
+
+ test('should return correct prompt and completion rates for Grok 4 model with prefixes', () => {
+ expect(getMultiplier({ model: 'xai/grok-4-0709', tokenType: 'prompt' })).toBe(
+ tokenValues['grok-4'].prompt,
+ );
+ expect(getMultiplier({ model: 'xai/grok-4-0709', tokenType: 'completion' })).toBe(
+ tokenValues['grok-4'].completion,
+ );
+ });
+
+ test('should return correct prompt and completion rates for Grok 4 Fast model with prefixes', () => {
+ expect(getMultiplier({ model: 'xai/grok-4-fast', tokenType: 'prompt' })).toBe(
+ tokenValues['grok-4-fast'].prompt,
+ );
+ expect(getMultiplier({ model: 'xai/grok-4-fast', tokenType: 'completion' })).toBe(
+ tokenValues['grok-4-fast'].completion,
+ );
+ });
+
+ test('should return correct prompt and completion rates for Grok 4.1 Fast models with prefixes', () => {
+ expect(getMultiplier({ model: 'xai/grok-4-1-fast-reasoning', tokenType: 'prompt' })).toBe(
+ tokenValues['grok-4-1-fast'].prompt,
+ );
+ expect(getMultiplier({ model: 'xai/grok-4-1-fast-reasoning', tokenType: 'completion' })).toBe(
+ tokenValues['grok-4-1-fast'].completion,
+ );
+ expect(getMultiplier({ model: 'xai/grok-4-1-fast-non-reasoning', tokenType: 'prompt' })).toBe(
+ tokenValues['grok-4-1-fast'].prompt,
+ );
+ expect(
+ getMultiplier({ model: 'xai/grok-4-1-fast-non-reasoning', tokenType: 'completion' }),
+ ).toBe(tokenValues['grok-4-1-fast'].completion);
+ });
+
+ test('should return correct prompt and completion rates for Grok Code Fast model with prefixes', () => {
+ expect(getMultiplier({ model: 'xai/grok-code-fast-1', tokenType: 'prompt' })).toBe(
+ tokenValues['grok-code-fast'].prompt,
+ );
+ expect(getMultiplier({ model: 'xai/grok-code-fast-1', tokenType: 'completion' })).toBe(
+ tokenValues['grok-code-fast'].completion,
+ );
+ });
+ });
+});
+
+describe('GLM Model Tests', () => {
+ it('should return expected value keys for GLM models', () => {
+ expect(getValueKey('glm-4.6')).toBe('glm-4.6');
+ expect(getValueKey('glm-4.5')).toBe('glm-4.5');
+ expect(getValueKey('glm-4.5v')).toBe('glm-4.5v');
+ expect(getValueKey('glm-4.5-air')).toBe('glm-4.5-air');
+ expect(getValueKey('glm-4-32b')).toBe('glm-4-32b');
+ expect(getValueKey('glm-4')).toBe('glm-4');
+ expect(getValueKey('glm4')).toBe('glm4');
+ });
+
+ it('should match GLM model variations with provider prefixes', () => {
+ expect(getValueKey('z-ai/glm-4.6')).toBe('glm-4.6');
+ expect(getValueKey('z-ai/glm-4.5')).toBe('glm-4.5');
+ expect(getValueKey('z-ai/glm-4.5-air')).toBe('glm-4.5-air');
+ expect(getValueKey('z-ai/glm-4.5v')).toBe('glm-4.5v');
+ expect(getValueKey('z-ai/glm-4-32b')).toBe('glm-4-32b');
+
+ expect(getValueKey('zai/glm-4.6')).toBe('glm-4.6');
+ expect(getValueKey('zai/glm-4.5')).toBe('glm-4.5');
+ expect(getValueKey('zai/glm-4.5-air')).toBe('glm-4.5-air');
+ expect(getValueKey('zai/glm-4.5v')).toBe('glm-4.5v');
+
+ expect(getValueKey('zai-org/GLM-4.6')).toBe('glm-4.6');
+ expect(getValueKey('zai-org/GLM-4.5')).toBe('glm-4.5');
+ expect(getValueKey('zai-org/GLM-4.5-Air')).toBe('glm-4.5-air');
+ expect(getValueKey('zai-org/GLM-4.5V')).toBe('glm-4.5v');
+ expect(getValueKey('zai-org/GLM-4-32B-0414')).toBe('glm-4-32b');
+ });
+
+ it('should match GLM model variations with suffixes', () => {
+ expect(getValueKey('glm-4.6-fp8')).toBe('glm-4.6');
+ expect(getValueKey('zai-org/GLM-4.6-FP8')).toBe('glm-4.6');
+ expect(getValueKey('zai-org/GLM-4.5-Air-FP8')).toBe('glm-4.5-air');
+ });
+
+ it('should prioritize more specific GLM model patterns', () => {
+ expect(getValueKey('glm-4.5-air-something')).toBe('glm-4.5-air');
+ expect(getValueKey('glm-4.5-something')).toBe('glm-4.5');
+ expect(getValueKey('glm-4.5v-something')).toBe('glm-4.5v');
+ });
+
+ it('should return correct multipliers for all GLM models', () => {
+ expect(getMultiplier({ model: 'glm-4.6', tokenType: 'prompt' })).toBe(
+ tokenValues['glm-4.6'].prompt,
+ );
+ expect(getMultiplier({ model: 'glm-4.6', tokenType: 'completion' })).toBe(
+ tokenValues['glm-4.6'].completion,
+ );
+
+ expect(getMultiplier({ model: 'glm-4.5v', tokenType: 'prompt' })).toBe(
+ tokenValues['glm-4.5v'].prompt,
+ );
+ expect(getMultiplier({ model: 'glm-4.5v', tokenType: 'completion' })).toBe(
+ tokenValues['glm-4.5v'].completion,
+ );
+
+ expect(getMultiplier({ model: 'glm-4.5-air', tokenType: 'prompt' })).toBe(
+ tokenValues['glm-4.5-air'].prompt,
+ );
+ expect(getMultiplier({ model: 'glm-4.5-air', tokenType: 'completion' })).toBe(
+ tokenValues['glm-4.5-air'].completion,
+ );
+
+ expect(getMultiplier({ model: 'glm-4.5', tokenType: 'prompt' })).toBe(
+ tokenValues['glm-4.5'].prompt,
+ );
+ expect(getMultiplier({ model: 'glm-4.5', tokenType: 'completion' })).toBe(
+ tokenValues['glm-4.5'].completion,
+ );
+
+ expect(getMultiplier({ model: 'glm-4-32b', tokenType: 'prompt' })).toBe(
+ tokenValues['glm-4-32b'].prompt,
+ );
+ expect(getMultiplier({ model: 'glm-4-32b', tokenType: 'completion' })).toBe(
+ tokenValues['glm-4-32b'].completion,
+ );
+
+ expect(getMultiplier({ model: 'glm-4', tokenType: 'prompt' })).toBe(
+ tokenValues['glm-4'].prompt,
+ );
+ expect(getMultiplier({ model: 'glm-4', tokenType: 'completion' })).toBe(
+ tokenValues['glm-4'].completion,
+ );
+
+ expect(getMultiplier({ model: 'glm4', tokenType: 'prompt' })).toBe(tokenValues['glm4'].prompt);
+ expect(getMultiplier({ model: 'glm4', tokenType: 'completion' })).toBe(
+ tokenValues['glm4'].completion,
+ );
+ });
+
+ it('should return correct multipliers for GLM models with provider prefixes', () => {
+ expect(getMultiplier({ model: 'z-ai/glm-4.6', tokenType: 'prompt' })).toBe(
+ tokenValues['glm-4.6'].prompt,
+ );
+ expect(getMultiplier({ model: 'zai/glm-4.5-air', tokenType: 'completion' })).toBe(
+ tokenValues['glm-4.5-air'].completion,
+ );
+ expect(getMultiplier({ model: 'zai-org/GLM-4.5V', tokenType: 'prompt' })).toBe(
+ tokenValues['glm-4.5v'].prompt,
+ );
+ });
+});
+
+describe('Claude Model Tests', () => {
+ it('should return correct prompt and completion rates for Claude 4 models', () => {
+ expect(getMultiplier({ model: 'claude-sonnet-4', tokenType: 'prompt' })).toBe(
+ tokenValues['claude-sonnet-4'].prompt,
+ );
+ expect(getMultiplier({ model: 'claude-sonnet-4', tokenType: 'completion' })).toBe(
+ tokenValues['claude-sonnet-4'].completion,
+ );
+ expect(getMultiplier({ model: 'claude-opus-4', tokenType: 'prompt' })).toBe(
+ tokenValues['claude-opus-4'].prompt,
+ );
+ expect(getMultiplier({ model: 'claude-opus-4', tokenType: 'completion' })).toBe(
+ tokenValues['claude-opus-4'].completion,
+ );
+ });
+
+ it('should return correct prompt and completion rates for Claude Haiku 4.5', () => {
+ expect(getMultiplier({ model: 'claude-haiku-4-5', tokenType: 'prompt' })).toBe(
+ tokenValues['claude-haiku-4-5'].prompt,
+ );
+ expect(getMultiplier({ model: 'claude-haiku-4-5', tokenType: 'completion' })).toBe(
+ tokenValues['claude-haiku-4-5'].completion,
+ );
+ });
+
+ it('should return correct prompt and completion rates for Claude Opus 4.5', () => {
+ expect(getMultiplier({ model: 'claude-opus-4-5', tokenType: 'prompt' })).toBe(
+ tokenValues['claude-opus-4-5'].prompt,
+ );
+ expect(getMultiplier({ model: 'claude-opus-4-5', tokenType: 'completion' })).toBe(
+ tokenValues['claude-opus-4-5'].completion,
+ );
+ });
+
+ it('should handle Claude Haiku 4.5 model name variations', () => {
+ const modelVariations = [
+ 'claude-haiku-4-5',
+ 'claude-haiku-4-5-20250420',
+ 'claude-haiku-4-5-latest',
+ 'anthropic/claude-haiku-4-5',
+ 'claude-haiku-4-5/anthropic',
+ 'claude-haiku-4-5-preview',
+ ];
+
+ modelVariations.forEach((model) => {
+ const valueKey = getValueKey(model);
+ expect(valueKey).toBe('claude-haiku-4-5');
+ expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(
+ tokenValues['claude-haiku-4-5'].prompt,
+ );
+ expect(getMultiplier({ model, tokenType: 'completion' })).toBe(
+ tokenValues['claude-haiku-4-5'].completion,
+ );
+ });
+ });
+
+ it('should handle Claude Opus 4.5 model name variations', () => {
+ const modelVariations = [
+ 'claude-opus-4-5',
+ 'claude-opus-4-5-20250420',
+ 'claude-opus-4-5-latest',
+ 'anthropic/claude-opus-4-5',
+ 'claude-opus-4-5/anthropic',
+ 'claude-opus-4-5-preview',
+ ];
+
+ modelVariations.forEach((model) => {
+ const valueKey = getValueKey(model);
+ expect(valueKey).toBe('claude-opus-4-5');
+ expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(
+ tokenValues['claude-opus-4-5'].prompt,
+ );
+ expect(getMultiplier({ model, tokenType: 'completion' })).toBe(
+ tokenValues['claude-opus-4-5'].completion,
+ );
+ });
+ });
+
+ it('should handle Claude 4 model name variations with different prefixes and suffixes', () => {
+ const modelVariations = [
+ 'claude-sonnet-4',
+ 'claude-sonnet-4-20240229',
+ 'claude-sonnet-4-latest',
+ 'anthropic/claude-sonnet-4',
+ 'claude-sonnet-4/anthropic',
+ 'claude-sonnet-4-preview',
+ 'claude-sonnet-4-20240229-preview',
+ 'claude-opus-4',
+ 'claude-opus-4-20240229',
+ 'claude-opus-4-latest',
+ 'anthropic/claude-opus-4',
+ 'claude-opus-4/anthropic',
+ 'claude-opus-4-preview',
+ 'claude-opus-4-20240229-preview',
+ ];
+
+ modelVariations.forEach((model) => {
+ const valueKey = getValueKey(model);
+ const isSonnet = model.includes('sonnet');
+ const expectedKey = isSonnet ? 'claude-sonnet-4' : 'claude-opus-4';
+
+ expect(valueKey).toBe(expectedKey);
+ expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(tokenValues[expectedKey].prompt);
+ expect(getMultiplier({ model, tokenType: 'completion' })).toBe(
+ tokenValues[expectedKey].completion,
+ );
+ });
+ });
+
+ it('should return correct cache rates for Claude 4 models', () => {
+ expect(getCacheMultiplier({ model: 'claude-sonnet-4', cacheType: 'write' })).toBe(
+ cacheTokenValues['claude-sonnet-4'].write,
+ );
+ expect(getCacheMultiplier({ model: 'claude-sonnet-4', cacheType: 'read' })).toBe(
+ cacheTokenValues['claude-sonnet-4'].read,
+ );
+ expect(getCacheMultiplier({ model: 'claude-opus-4', cacheType: 'write' })).toBe(
+ cacheTokenValues['claude-opus-4'].write,
+ );
+ expect(getCacheMultiplier({ model: 'claude-opus-4', cacheType: 'read' })).toBe(
+ cacheTokenValues['claude-opus-4'].read,
+ );
+ });
+
+ it('should return correct cache rates for Claude Opus 4.5', () => {
+ expect(getCacheMultiplier({ model: 'claude-opus-4-5', cacheType: 'write' })).toBe(
+ cacheTokenValues['claude-opus-4-5'].write,
+ );
+ expect(getCacheMultiplier({ model: 'claude-opus-4-5', cacheType: 'read' })).toBe(
+ cacheTokenValues['claude-opus-4-5'].read,
+ );
+ });
+
+ it('should handle Claude 4 model cache rates with different prefixes and suffixes', () => {
+ const modelVariations = [
+ 'claude-sonnet-4',
+ 'claude-sonnet-4-20240229',
+ 'claude-sonnet-4-latest',
+ 'anthropic/claude-sonnet-4',
+ 'claude-sonnet-4/anthropic',
+ 'claude-sonnet-4-preview',
+ 'claude-sonnet-4-20240229-preview',
+ 'claude-opus-4',
+ 'claude-opus-4-20240229',
+ 'claude-opus-4-latest',
+ 'anthropic/claude-opus-4',
+ 'claude-opus-4/anthropic',
+ 'claude-opus-4-preview',
+ 'claude-opus-4-20240229-preview',
+ ];
+
+ modelVariations.forEach((model) => {
+ const isSonnet = model.includes('sonnet');
+ const expectedKey = isSonnet ? 'claude-sonnet-4' : 'claude-opus-4';
+
+ expect(getCacheMultiplier({ model, cacheType: 'write' })).toBe(
+ cacheTokenValues[expectedKey].write,
+ );
+ expect(getCacheMultiplier({ model, cacheType: 'read' })).toBe(
+ cacheTokenValues[expectedKey].read,
+ );
+ });
+ });
+});
+
+describe('tokens.ts and tx.js sync validation', () => {
+ it('should resolve all models in maxTokensMap to pricing via getValueKey', () => {
+ const tokensKeys = Object.keys(maxTokensMap[EModelEndpoint.openAI]);
+ const txKeys = Object.keys(tokenValues);
+
+ const unresolved = [];
+
+ tokensKeys.forEach((key) => {
+ // Skip legacy token size mappings (e.g., '4k', '8k', '16k', '32k')
+ if (/^\d+k$/.test(key)) return;
+
+ // Skip generic pattern keys (end with '-' or ':')
+ if (key.endsWith('-') || key.endsWith(':')) return;
+
+ // Try to resolve via getValueKey
+ const resolvedKey = getValueKey(key);
+
+ // If it resolves and the resolved key has pricing, success
+ if (resolvedKey && txKeys.includes(resolvedKey)) return;
+
+ // If it resolves to a legacy key (4k, 8k, etc), also OK
+ if (resolvedKey && /^\d+k$/.test(resolvedKey)) return;
+
+ // If we get here, this model can't get pricing - flag it
+ unresolved.push({
+ key,
+ resolvedKey: resolvedKey || 'undefined',
+ context: maxTokensMap[EModelEndpoint.openAI][key],
+ });
+ });
+
+ if (unresolved.length > 0) {
+ console.log('\nModels that cannot resolve to pricing via getValueKey:');
+ unresolved.forEach(({ key, resolvedKey, context }) => {
+ console.log(` - '${key}' → '${resolvedKey}' (context: ${context})`);
+ });
+ }
+
+ expect(unresolved).toEqual([]);
+ });
+
+ it('should not have redundant dated variants with same pricing and context as base model', () => {
+ const txKeys = Object.keys(tokenValues);
+ const redundant = [];
+
+ txKeys.forEach((key) => {
+ // Check if this is a dated variant (ends with -YYYY-MM-DD)
+ if (key.match(/.*-\d{4}-\d{2}-\d{2}$/)) {
+ const baseKey = key.replace(/-\d{4}-\d{2}-\d{2}$/, '');
+
+ if (txKeys.includes(baseKey)) {
+ const variantPricing = tokenValues[key];
+ const basePricing = tokenValues[baseKey];
+ const variantContext = maxTokensMap[EModelEndpoint.openAI][key];
+ const baseContext = maxTokensMap[EModelEndpoint.openAI][baseKey];
+
+ const samePricing =
+ variantPricing.prompt === basePricing.prompt &&
+ variantPricing.completion === basePricing.completion;
+ const sameContext = variantContext === baseContext;
+
+ if (samePricing && sameContext) {
+ redundant.push({
+ key,
+ baseKey,
+ pricing: `${variantPricing.prompt}/${variantPricing.completion}`,
+ context: variantContext,
+ });
+ }
+ }
+ }
+ });
+
+ if (redundant.length > 0) {
+ console.log('\nRedundant dated variants found (same pricing and context as base):');
+ redundant.forEach(({ key, baseKey, pricing, context }) => {
+ console.log(` - '${key}' → '${baseKey}' (pricing: ${pricing}, context: ${context})`);
+ console.log(` Can be removed - pattern matching will handle it`);
+ });
+ }
+
+ expect(redundant).toEqual([]);
+ });
+
+ it('should have context windows in tokens.ts for all models with pricing in tx.js (openAI catch-all)', () => {
+ const txKeys = Object.keys(tokenValues);
+ const missingContext = [];
+
+ txKeys.forEach((key) => {
+ // Skip legacy token size mappings (4k, 8k, 16k, 32k)
+ if (/^\d+k$/.test(key)) return;
+
+ // Check if this model has a context window defined
+ const context = maxTokensMap[EModelEndpoint.openAI][key];
+
+ if (!context) {
+ const pricing = tokenValues[key];
+ missingContext.push({
+ key,
+ pricing: `${pricing.prompt}/${pricing.completion}`,
+ });
+ }
+ });
+
+ if (missingContext.length > 0) {
+ console.log('\nModels with pricing but missing context in tokens.ts:');
+ missingContext.forEach(({ key, pricing }) => {
+ console.log(` - '${key}' (pricing: ${pricing})`);
+ console.log(` Add to tokens.ts openAIModels/bedrockModels/etc.`);
+ });
+ }
+
+ expect(missingContext).toEqual([]);
+ });
+});
diff --git a/api/models/userMethods.js b/api/models/userMethods.js
new file mode 100644
index 0000000000..b57b24e641
--- /dev/null
+++ b/api/models/userMethods.js
@@ -0,0 +1,31 @@
+const bcrypt = require('bcryptjs');
+
+/**
+ * Compares the provided password with the user's password.
+ *
+ * @param {IUser} user - The user to compare the password for.
+ * @param {string} candidatePassword - The password to test against the user's password.
+ * @returns {Promise} A promise that resolves to a boolean indicating if the password matches.
+ */
+const comparePassword = async (user, candidatePassword) => {
+ if (!user) {
+ throw new Error('No user provided');
+ }
+
+ if (!user.password) {
+ throw new Error('No password, likely an email first registered via Social/OIDC login');
+ }
+
+ return new Promise((resolve, reject) => {
+ bcrypt.compare(candidatePassword, user.password, (err, isMatch) => {
+ if (err) {
+ reject(err);
+ }
+ resolve(isMatch);
+ });
+ });
+};
+
+module.exports = {
+ comparePassword,
+};
diff --git a/api/nodemon.json b/api/nodemon.json
deleted file mode 100644
index b934448014..0000000000
--- a/api/nodemon.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "ignore": [
- "api/data/",
- "data"
- ]
-}
\ No newline at end of file
diff --git a/api/package-lock.json b/api/package-lock.json
deleted file mode 100644
index 911fda953e..0000000000
--- a/api/package-lock.json
+++ /dev/null
@@ -1,7893 +0,0 @@
-{
- "name": "chatgpt-clone",
- "version": "1.0.0",
- "lockfileVersion": 2,
- "requires": true,
- "packages": {
- "": {
- "name": "chatgpt-clone",
- "version": "1.0.0",
- "license": "ISC",
- "dependencies": {
- "@keyv/mongo": "^2.1.8",
- "@vscode/vscode-languagedetection": "^1.0.22",
- "@waylaidwanderer/chatgpt-api": "^1.28.2",
- "cors": "^2.8.5",
- "dotenv": "^16.0.3",
- "express": "^4.18.2",
- "keyv": "^4.5.2",
- "keyv-file": "^0.2.0",
- "mongoose": "^6.9.0",
- "openai": "^3.1.0"
- },
- "devDependencies": {
- "nodemon": "^2.0.20",
- "path": "^0.12.7"
- }
- },
- "node_modules/@aws-crypto/ie11-detection": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-3.0.0.tgz",
- "integrity": "sha512-341lBBkiY1DfDNKai/wXM3aujNBkXR7tq1URPQDL9wi3AUbI80NR74uF1TXHMm7po1AcnFk8iu2S2IeU/+/A+Q==",
- "optional": true,
- "dependencies": {
- "tslib": "^1.11.1"
- }
- },
- "node_modules/@aws-crypto/sha256-browser": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-3.0.0.tgz",
- "integrity": "sha512-8VLmW2B+gjFbU5uMeqtQM6Nj0/F1bro80xQXCW6CQBWgosFWXTx77aeOF5CAIAmbOK64SdMBJdNr6J41yP5mvQ==",
- "optional": true,
- "dependencies": {
- "@aws-crypto/ie11-detection": "^3.0.0",
- "@aws-crypto/sha256-js": "^3.0.0",
- "@aws-crypto/supports-web-crypto": "^3.0.0",
- "@aws-crypto/util": "^3.0.0",
- "@aws-sdk/types": "^3.222.0",
- "@aws-sdk/util-locate-window": "^3.0.0",
- "@aws-sdk/util-utf8-browser": "^3.0.0",
- "tslib": "^1.11.1"
- }
- },
- "node_modules/@aws-crypto/sha256-js": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-3.0.0.tgz",
- "integrity": "sha512-PnNN7os0+yd1XvXAy23CFOmTbMaDxgxXtTKHybrJ39Y8kGzBATgBFibWJKH6BhytLI/Zyszs87xCOBNyBig6vQ==",
- "optional": true,
- "dependencies": {
- "@aws-crypto/util": "^3.0.0",
- "@aws-sdk/types": "^3.222.0",
- "tslib": "^1.11.1"
- }
- },
- "node_modules/@aws-crypto/supports-web-crypto": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-3.0.0.tgz",
- "integrity": "sha512-06hBdMwUAb2WFTuGG73LSC0wfPu93xWwo5vL2et9eymgmu3Id5vFAHBbajVWiGhPO37qcsdCap/FqXvJGJWPIg==",
- "optional": true,
- "dependencies": {
- "tslib": "^1.11.1"
- }
- },
- "node_modules/@aws-crypto/util": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-3.0.0.tgz",
- "integrity": "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/types": "^3.222.0",
- "@aws-sdk/util-utf8-browser": "^3.0.0",
- "tslib": "^1.11.1"
- }
- },
- "node_modules/@aws-sdk/abort-controller": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/abort-controller/-/abort-controller-3.272.0.tgz",
- "integrity": "sha512-s2TV3phapcTwZNr4qLxbfuQuE9ZMP4RoJdkvRRCkKdm6jslsWLJf2Zlcxti/23hOlINUMYv2iXE2pftIgWGdpg==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/abort-controller/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/client-cognito-identity": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.282.0.tgz",
- "integrity": "sha512-OU9Wy50u31Mog4xmj9o+lLOb/y+yuQBTFwEVYApJtCkPsI2e3DtZFt36IcAy04fcjNUaSD3u6SGgfYo2vDQ2zA==",
- "optional": true,
- "dependencies": {
- "@aws-crypto/sha256-browser": "3.0.0",
- "@aws-crypto/sha256-js": "3.0.0",
- "@aws-sdk/client-sts": "3.282.0",
- "@aws-sdk/config-resolver": "3.282.0",
- "@aws-sdk/credential-provider-node": "3.282.0",
- "@aws-sdk/fetch-http-handler": "3.282.0",
- "@aws-sdk/hash-node": "3.272.0",
- "@aws-sdk/invalid-dependency": "3.272.0",
- "@aws-sdk/middleware-content-length": "3.282.0",
- "@aws-sdk/middleware-endpoint": "3.282.0",
- "@aws-sdk/middleware-host-header": "3.282.0",
- "@aws-sdk/middleware-logger": "3.272.0",
- "@aws-sdk/middleware-recursion-detection": "3.282.0",
- "@aws-sdk/middleware-retry": "3.282.0",
- "@aws-sdk/middleware-serde": "3.272.0",
- "@aws-sdk/middleware-signing": "3.282.0",
- "@aws-sdk/middleware-stack": "3.272.0",
- "@aws-sdk/middleware-user-agent": "3.282.0",
- "@aws-sdk/node-config-provider": "3.272.0",
- "@aws-sdk/node-http-handler": "3.282.0",
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/smithy-client": "3.279.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/url-parser": "3.272.0",
- "@aws-sdk/util-base64": "3.208.0",
- "@aws-sdk/util-body-length-browser": "3.188.0",
- "@aws-sdk/util-body-length-node": "3.208.0",
- "@aws-sdk/util-defaults-mode-browser": "3.279.0",
- "@aws-sdk/util-defaults-mode-node": "3.282.0",
- "@aws-sdk/util-endpoints": "3.272.0",
- "@aws-sdk/util-retry": "3.272.0",
- "@aws-sdk/util-user-agent-browser": "3.282.0",
- "@aws-sdk/util-user-agent-node": "3.282.0",
- "@aws-sdk/util-utf8": "3.254.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/client-cognito-identity/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/client-sso": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.282.0.tgz",
- "integrity": "sha512-VzdCCaxlDyU+7wvLDWh+uACQ6RPfaKLQ3yJ2UY0B0SkH4R0E4GLDJ2OJzqS5eyyOsnq1rxfY75S4WYzj8E2cvg==",
- "optional": true,
- "dependencies": {
- "@aws-crypto/sha256-browser": "3.0.0",
- "@aws-crypto/sha256-js": "3.0.0",
- "@aws-sdk/config-resolver": "3.282.0",
- "@aws-sdk/fetch-http-handler": "3.282.0",
- "@aws-sdk/hash-node": "3.272.0",
- "@aws-sdk/invalid-dependency": "3.272.0",
- "@aws-sdk/middleware-content-length": "3.282.0",
- "@aws-sdk/middleware-endpoint": "3.282.0",
- "@aws-sdk/middleware-host-header": "3.282.0",
- "@aws-sdk/middleware-logger": "3.272.0",
- "@aws-sdk/middleware-recursion-detection": "3.282.0",
- "@aws-sdk/middleware-retry": "3.282.0",
- "@aws-sdk/middleware-serde": "3.272.0",
- "@aws-sdk/middleware-stack": "3.272.0",
- "@aws-sdk/middleware-user-agent": "3.282.0",
- "@aws-sdk/node-config-provider": "3.272.0",
- "@aws-sdk/node-http-handler": "3.282.0",
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/smithy-client": "3.279.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/url-parser": "3.272.0",
- "@aws-sdk/util-base64": "3.208.0",
- "@aws-sdk/util-body-length-browser": "3.188.0",
- "@aws-sdk/util-body-length-node": "3.208.0",
- "@aws-sdk/util-defaults-mode-browser": "3.279.0",
- "@aws-sdk/util-defaults-mode-node": "3.282.0",
- "@aws-sdk/util-endpoints": "3.272.0",
- "@aws-sdk/util-retry": "3.272.0",
- "@aws-sdk/util-user-agent-browser": "3.282.0",
- "@aws-sdk/util-user-agent-node": "3.282.0",
- "@aws-sdk/util-utf8": "3.254.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/client-sso-oidc": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.282.0.tgz",
- "integrity": "sha512-upC4yBZllAXg5OVIuS8Lu9MI1aqfAObl2BBixj9fIYbDanQ02s0b1IwfZqlOqNNkGzMko1AWyiOSyOdVgyJ+xg==",
- "optional": true,
- "dependencies": {
- "@aws-crypto/sha256-browser": "3.0.0",
- "@aws-crypto/sha256-js": "3.0.0",
- "@aws-sdk/config-resolver": "3.282.0",
- "@aws-sdk/fetch-http-handler": "3.282.0",
- "@aws-sdk/hash-node": "3.272.0",
- "@aws-sdk/invalid-dependency": "3.272.0",
- "@aws-sdk/middleware-content-length": "3.282.0",
- "@aws-sdk/middleware-endpoint": "3.282.0",
- "@aws-sdk/middleware-host-header": "3.282.0",
- "@aws-sdk/middleware-logger": "3.272.0",
- "@aws-sdk/middleware-recursion-detection": "3.282.0",
- "@aws-sdk/middleware-retry": "3.282.0",
- "@aws-sdk/middleware-serde": "3.272.0",
- "@aws-sdk/middleware-stack": "3.272.0",
- "@aws-sdk/middleware-user-agent": "3.282.0",
- "@aws-sdk/node-config-provider": "3.272.0",
- "@aws-sdk/node-http-handler": "3.282.0",
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/smithy-client": "3.279.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/url-parser": "3.272.0",
- "@aws-sdk/util-base64": "3.208.0",
- "@aws-sdk/util-body-length-browser": "3.188.0",
- "@aws-sdk/util-body-length-node": "3.208.0",
- "@aws-sdk/util-defaults-mode-browser": "3.279.0",
- "@aws-sdk/util-defaults-mode-node": "3.282.0",
- "@aws-sdk/util-endpoints": "3.272.0",
- "@aws-sdk/util-retry": "3.272.0",
- "@aws-sdk/util-user-agent-browser": "3.282.0",
- "@aws-sdk/util-user-agent-node": "3.282.0",
- "@aws-sdk/util-utf8": "3.254.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/client-sso-oidc/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/client-sso/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/client-sts": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.282.0.tgz",
- "integrity": "sha512-JZybEaST0rloS9drlX/0yJAnKHuV7DlS1n1WZxgaM2DY704ydlGiviiPQvC/q/dItsX4017gscC0blGJcUjK1g==",
- "optional": true,
- "dependencies": {
- "@aws-crypto/sha256-browser": "3.0.0",
- "@aws-crypto/sha256-js": "3.0.0",
- "@aws-sdk/config-resolver": "3.282.0",
- "@aws-sdk/credential-provider-node": "3.282.0",
- "@aws-sdk/fetch-http-handler": "3.282.0",
- "@aws-sdk/hash-node": "3.272.0",
- "@aws-sdk/invalid-dependency": "3.272.0",
- "@aws-sdk/middleware-content-length": "3.282.0",
- "@aws-sdk/middleware-endpoint": "3.282.0",
- "@aws-sdk/middleware-host-header": "3.282.0",
- "@aws-sdk/middleware-logger": "3.272.0",
- "@aws-sdk/middleware-recursion-detection": "3.282.0",
- "@aws-sdk/middleware-retry": "3.282.0",
- "@aws-sdk/middleware-sdk-sts": "3.282.0",
- "@aws-sdk/middleware-serde": "3.272.0",
- "@aws-sdk/middleware-signing": "3.282.0",
- "@aws-sdk/middleware-stack": "3.272.0",
- "@aws-sdk/middleware-user-agent": "3.282.0",
- "@aws-sdk/node-config-provider": "3.272.0",
- "@aws-sdk/node-http-handler": "3.282.0",
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/smithy-client": "3.279.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/url-parser": "3.272.0",
- "@aws-sdk/util-base64": "3.208.0",
- "@aws-sdk/util-body-length-browser": "3.188.0",
- "@aws-sdk/util-body-length-node": "3.208.0",
- "@aws-sdk/util-defaults-mode-browser": "3.279.0",
- "@aws-sdk/util-defaults-mode-node": "3.282.0",
- "@aws-sdk/util-endpoints": "3.272.0",
- "@aws-sdk/util-retry": "3.272.0",
- "@aws-sdk/util-user-agent-browser": "3.282.0",
- "@aws-sdk/util-user-agent-node": "3.282.0",
- "@aws-sdk/util-utf8": "3.254.0",
- "fast-xml-parser": "4.1.2",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/client-sts/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/config-resolver": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/config-resolver/-/config-resolver-3.282.0.tgz",
- "integrity": "sha512-30qFLh2N4NXQ2EAook7NIFeu1K/nlrRLrdVb2BtGFi/F3cZnz+sy9o0XmL6x+sO9TznWjdNxD1RKQdqoAwGnCQ==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/signature-v4": "3.282.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/util-config-provider": "3.208.0",
- "@aws-sdk/util-middleware": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/config-resolver/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/credential-provider-cognito-identity": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.282.0.tgz",
- "integrity": "sha512-GsLOt6GzckLQbMzgXOblKcRtXyMu3NcP0vFkYpy4r9oEzoxqPhy1yUpRNLeDv7r2qoa8naN81F5FwPwd17PrKg==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/client-cognito-identity": "3.282.0",
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/credential-provider-cognito-identity/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/credential-provider-env": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.272.0.tgz",
- "integrity": "sha512-QI65NbLnKLYHyTYhXaaUrq6eVsCCrMUb05WDA7+TJkWkjXesovpjc8vUKgFiLSxmgKmb2uOhHNcDyObKMrYQFw==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/credential-provider-env/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/credential-provider-imds": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-imds/-/credential-provider-imds-3.272.0.tgz",
- "integrity": "sha512-wwAfVY1jTFQEfxVfdYD5r5ieYGl+0g4nhekVxNMqE8E1JeRDd18OqiwAflzpgBIqxfqvCUkf+vl5JYyacMkNAQ==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/node-config-provider": "3.272.0",
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/url-parser": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/credential-provider-imds/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/credential-provider-ini": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.282.0.tgz",
- "integrity": "sha512-2GKduXORcUgOigF1jZF7A1Wh4W/aJt3ynh7xb1vfx020nHx6YDljrEGpzgH6pOVzl7ZhgthpojicCuy2UumkMA==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/credential-provider-env": "3.272.0",
- "@aws-sdk/credential-provider-imds": "3.272.0",
- "@aws-sdk/credential-provider-process": "3.272.0",
- "@aws-sdk/credential-provider-sso": "3.282.0",
- "@aws-sdk/credential-provider-web-identity": "3.272.0",
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/shared-ini-file-loader": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/credential-provider-ini/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/credential-provider-node": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.282.0.tgz",
- "integrity": "sha512-qyHipZW0ep8STY+SO+Me8ObQ1Ee/aaZTmAK0Os/gB+EsiZhIE+mi6zRcScwdnpgJPLRYMEe4p/Cr6DOrA0G0GQ==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/credential-provider-env": "3.272.0",
- "@aws-sdk/credential-provider-imds": "3.272.0",
- "@aws-sdk/credential-provider-ini": "3.282.0",
- "@aws-sdk/credential-provider-process": "3.272.0",
- "@aws-sdk/credential-provider-sso": "3.282.0",
- "@aws-sdk/credential-provider-web-identity": "3.272.0",
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/shared-ini-file-loader": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/credential-provider-node/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/credential-provider-process": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.272.0.tgz",
- "integrity": "sha512-hiCAjWWm2PeBFp5cjkxqyam/XADjiS+e7GzwC34TbZn3LisS0uoweLojj9tD11NnnUhyhbLteUvu5+rotOLwrg==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/shared-ini-file-loader": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/credential-provider-process/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/credential-provider-sso": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.282.0.tgz",
- "integrity": "sha512-c4nibry7u0hkYRMi7+cWzdwYXfDDG+j3VYFxk2oOvU1VIJRyE6oeJqVaz3jgYLX9brHyrLJjuFCIJCUV/WXgIA==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/client-sso": "3.282.0",
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/shared-ini-file-loader": "3.272.0",
- "@aws-sdk/token-providers": "3.282.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/credential-provider-sso/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/credential-provider-web-identity": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.272.0.tgz",
- "integrity": "sha512-ImrHMkcgneGa/HadHAQXPwOrX26sAKuB8qlMxZF/ZCM2B55u8deY+ZVkVuraeKb7YsahMGehPFOfRAF6mvFI5Q==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/credential-provider-web-identity/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/credential-providers": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.282.0.tgz",
- "integrity": "sha512-/Pau2Ht15j26ibTSTaJHbx6wA3suNT0Qgu+++6ZUoVCeHL5ZN/otcoebsR/lOZTw8Fji7K5kl8TW41UNAE8s2w==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/client-cognito-identity": "3.282.0",
- "@aws-sdk/client-sso": "3.282.0",
- "@aws-sdk/client-sts": "3.282.0",
- "@aws-sdk/credential-provider-cognito-identity": "3.282.0",
- "@aws-sdk/credential-provider-env": "3.272.0",
- "@aws-sdk/credential-provider-imds": "3.272.0",
- "@aws-sdk/credential-provider-ini": "3.282.0",
- "@aws-sdk/credential-provider-node": "3.282.0",
- "@aws-sdk/credential-provider-process": "3.272.0",
- "@aws-sdk/credential-provider-sso": "3.282.0",
- "@aws-sdk/credential-provider-web-identity": "3.272.0",
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/shared-ini-file-loader": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/credential-providers/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/fetch-http-handler": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/fetch-http-handler/-/fetch-http-handler-3.282.0.tgz",
- "integrity": "sha512-RTd53UzKtUucIEdVLGGgtlbVwp0QkOt3ZfHuA/A1lOH7meChSh1kz7B5z3p4HQDpXO+MQ1Y6Ble9Vg2fh1zwJQ==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/querystring-builder": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/util-base64": "3.208.0",
- "tslib": "^2.3.1"
- }
- },
- "node_modules/@aws-sdk/fetch-http-handler/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/hash-node": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/hash-node/-/hash-node-3.272.0.tgz",
- "integrity": "sha512-40dwND+iAm3VtPHPZu7/+CIdVJFk2s0cWZt1lOiMPMSXycSYJ45wMk7Lly3uoqRx0uWfFK5iT2OCv+fJi5jTng==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/util-buffer-from": "3.208.0",
- "@aws-sdk/util-utf8": "3.254.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/hash-node/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/invalid-dependency": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/invalid-dependency/-/invalid-dependency-3.272.0.tgz",
- "integrity": "sha512-ysW6wbjl1Y78txHUQ/Tldj2Rg1BI7rpMO9B9xAF6yAX3mQ7t6SUPQG/ewOGvH2208NBIl3qP5e/hDf0Q6r/1iw==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- }
- },
- "node_modules/@aws-sdk/invalid-dependency/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/is-array-buffer": {
- "version": "3.201.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/is-array-buffer/-/is-array-buffer-3.201.0.tgz",
- "integrity": "sha512-UPez5qLh3dNgt0DYnPD/q0mVJY84rA17QE26hVNOW3fAji8W2wrwrxdacWOxyXvlxWsVRcKmr+lay1MDqpAMfg==",
- "optional": true,
- "dependencies": {
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/is-array-buffer/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/middleware-content-length": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-content-length/-/middleware-content-length-3.282.0.tgz",
- "integrity": "sha512-SDgMLRRTMr9LlHSNk4bXUXynYnkT4oNMqE+FxhjsdbT8hK36eS4AadM58R7nPwgjR3EuWRW4ZRRawLWatpWspA==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/middleware-content-length/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/middleware-endpoint": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint/-/middleware-endpoint-3.282.0.tgz",
- "integrity": "sha512-8U9Mv/Sbdo1KI6/ip7IIUdBl5pgmalFbfkYAyO+AtmkEvawI9ipdWFs5HB0Dwd1BGVup5choY72Ik/7sCAAFTQ==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/middleware-serde": "3.272.0",
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/signature-v4": "3.282.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/url-parser": "3.272.0",
- "@aws-sdk/util-config-provider": "3.208.0",
- "@aws-sdk/util-middleware": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/middleware-endpoint/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/middleware-host-header": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.282.0.tgz",
- "integrity": "sha512-90dfYow4zh4tCatTOnqB3nE/dIAucQLZnMqwN/WBPu0fUqjymzpsNkPchqWBPnSWdNE8w3PiKMqqD9rjYwqw4Q==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/middleware-host-header/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/middleware-logger": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.272.0.tgz",
- "integrity": "sha512-u2SQ0hWrFwxbxxYMG5uMEgf01pQY5jauK/LYWgGIvuCmFgiyRQQP3oN7kkmsxnS9MWmNmhbyQguX2NY02s5e9w==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/middleware-logger/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/middleware-recursion-detection": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.282.0.tgz",
- "integrity": "sha512-cSLq/daEaTEucbP/TgAXIOcpwLu7Bfw3VGzH1U56ngDjI4KWvUheF16JiB6OqKQXduPBPsdZ9dVmkDVKddmCRw==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/middleware-recursion-detection/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/middleware-retry": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-retry/-/middleware-retry-3.282.0.tgz",
- "integrity": "sha512-3+0M1GP9o480IdqHVZbkhTgge63uKhDFlS6cQznpNGj0eIuQPhXRnlEz2/rma0INUqFm6+7qJ5yzHR4WQbfHpw==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/service-error-classification": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/util-middleware": "3.272.0",
- "@aws-sdk/util-retry": "3.272.0",
- "tslib": "^2.3.1",
- "uuid": "^8.3.2"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/middleware-retry/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/middleware-sdk-sts": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sts/-/middleware-sdk-sts-3.282.0.tgz",
- "integrity": "sha512-Qe20mtJcF6lxt7280FhTFD2IpBDn39MEXmbm/zIkXR2/cAmvji8YhcxhNrq1l7XiuMM6SokBDC/f3dlF1oOC6g==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/middleware-signing": "3.282.0",
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/signature-v4": "3.282.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/middleware-sdk-sts/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/middleware-serde": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-serde/-/middleware-serde-3.272.0.tgz",
- "integrity": "sha512-kW1uOxgPSwtXPB5rm3QLdWomu42lkYpQL94tM1BjyFOWmBLO2lQhk5a7Dw6HkTozT9a+vxtscLChRa6KZe61Hw==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/middleware-serde/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/middleware-signing": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.282.0.tgz",
- "integrity": "sha512-eE5qMDcqqxZPdSwybUEph/knrA2j2cHjW+B2ddROw3Ojg0XLjep5hOhithAudgBREQhYF9pdsBr6mUMynUIrKw==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/signature-v4": "3.282.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/util-middleware": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/middleware-signing/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/middleware-stack": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-stack/-/middleware-stack-3.272.0.tgz",
- "integrity": "sha512-jhwhknnPBGhfXAGV5GXUWfEhDFoP/DN8MPCO2yC5OAxyp6oVJ8lTPLkZYMTW5VL0c0eG44dXpF4Ib01V+PlDrQ==",
- "optional": true,
- "dependencies": {
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/middleware-stack/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/middleware-user-agent": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.282.0.tgz",
- "integrity": "sha512-P1ealsSrUALo0w0Qu5nBKsNQwsmqIfsoNtFWpaznjIcXE5rRMlZL69zb0KnGbQCBfEXsgaMOWjeGT8I3/XbOHQ==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/middleware-user-agent/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/node-config-provider": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/node-config-provider/-/node-config-provider-3.272.0.tgz",
- "integrity": "sha512-YYCIBh9g1EQo7hm2l22HX5Yr9RoPQ2RCvhzKvF1n1e8t1QH4iObQrYUtqHG4khcm64Cft8C5MwZmgzHbya5Z6Q==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/shared-ini-file-loader": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/node-config-provider/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/node-http-handler": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/node-http-handler/-/node-http-handler-3.282.0.tgz",
- "integrity": "sha512-LIA4lsSKA/l1kTR5ERkJG2gARveB7Y40MR6yDwtIuhXeVu7Xo9m4BJFanCYIbyc093W0T53x438bwoBR+R+/fw==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/abort-controller": "3.272.0",
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/querystring-builder": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/node-http-handler/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/property-provider": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/property-provider/-/property-provider-3.272.0.tgz",
- "integrity": "sha512-V1pZTaH5eqpAt8O8CzbItHhOtzIfFuWymvwZFkAtwKuaHpnl7jjrTouV482zoq8AD/fF+VVSshwBKYA7bhidIw==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/property-provider/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/protocol-http": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/protocol-http/-/protocol-http-3.282.0.tgz",
- "integrity": "sha512-aOPv5DhsbG06WKfeh2g0H8RGnaeI8pLhaA+Mq1BvzXcghhlDu+FM9K/GjC/f1lWk1UNryfevOR7SdQm95ciHQg==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/protocol-http/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/querystring-builder": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-builder/-/querystring-builder-3.272.0.tgz",
- "integrity": "sha512-ndo++7GkdCj5tBXE6rGcITpSpZS4PfyV38wntGYAlj9liL1omk3bLZRY6uzqqkJpVHqbg2fD7O2qHNItzZgqhw==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/util-uri-escape": "3.201.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/querystring-builder/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/querystring-parser": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-parser/-/querystring-parser-3.272.0.tgz",
- "integrity": "sha512-5oS4/9n6N1LZW9tI3qq/0GnCuWoOXRgcHVB+AJLRBvDbEe+GI+C/xK1tKLsfpDNgsQJHc4IPQoIt4megyZ/1+A==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/querystring-parser/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/service-error-classification": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/service-error-classification/-/service-error-classification-3.272.0.tgz",
- "integrity": "sha512-REoltM1LK9byyIufLqx9znhSolPcHQgVHIA2S0zu5sdt5qER4OubkLAXuo4MBbisUTmh8VOOvIyUb5ijZCXq1w==",
- "optional": true,
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/shared-ini-file-loader": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/shared-ini-file-loader/-/shared-ini-file-loader-3.272.0.tgz",
- "integrity": "sha512-lzFPohp5sy2XvwFjZIzLVCRpC0i5cwBiaXmFzXYQZJm6FSCszHO4ax+m9yrtlyVFF/2YPWl+/bzNthy4aJtseA==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/shared-ini-file-loader/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/signature-v4": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4/-/signature-v4-3.282.0.tgz",
- "integrity": "sha512-rnSL3UyF/No7+O2EMtN1sTCiqL1a+odbfnfo3wCSl8DH5PEYINt2kZgVEvT1Fgaffk1pUggBBOZoR+arPIIDJA==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/is-array-buffer": "3.201.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/util-hex-encoding": "3.201.0",
- "@aws-sdk/util-middleware": "3.272.0",
- "@aws-sdk/util-uri-escape": "3.201.0",
- "@aws-sdk/util-utf8": "3.254.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/signature-v4/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/smithy-client": {
- "version": "3.279.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/smithy-client/-/smithy-client-3.279.0.tgz",
- "integrity": "sha512-ZcYWUQDGAYN6NXRpJuSn46PetrpPCA6TrDVwP9+3pERzTXZ66npXoG2XhHjNrOXy/Ted5A3OxKrM4/zLu9tK3A==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/middleware-stack": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/smithy-client/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/token-providers": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.282.0.tgz",
- "integrity": "sha512-Qk/D6i+Hpc0fp/2SRHbfJeKPgUIugzsmye3NL0OV1bqd1Y40dW5LT4u67VcZHwqxzYDKe6Eo+7NHJu7qfvwhog==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/client-sso-oidc": "3.282.0",
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/shared-ini-file-loader": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/token-providers/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/types": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.272.0.tgz",
- "integrity": "sha512-MmmL6vxMGP5Bsi+4wRx4mxYlU/LX6M0noOXrDh/x5FfG7/4ZOar/nDxqDadhJtNM88cuWVHZWY59P54JzkGWmA==",
- "optional": true,
- "dependencies": {
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/types/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/url-parser": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/url-parser/-/url-parser-3.272.0.tgz",
- "integrity": "sha512-vX/Tx02PlnQ/Kgtf5TnrNDHPNbY+amLZjW0Z1d9vzAvSZhQ4i9Y18yxoRDIaDTCNVRDjdhV8iuctW+05PB5JtQ==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/querystring-parser": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- }
- },
- "node_modules/@aws-sdk/url-parser/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/util-base64": {
- "version": "3.208.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-base64/-/util-base64-3.208.0.tgz",
- "integrity": "sha512-PQniZph5A6N7uuEOQi+1hnMz/FSOK/8kMFyFO+4DgA1dZ5pcKcn5wiFwHkcTb/BsgVqQa3Jx0VHNnvhlS8JyTg==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/util-buffer-from": "3.208.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/util-base64/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/util-body-length-browser": {
- "version": "3.188.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-body-length-browser/-/util-body-length-browser-3.188.0.tgz",
- "integrity": "sha512-8VpnwFWXhnZ/iRSl9mTf+VKOX9wDE8QtN4bj9pBfxwf90H1X7E8T6NkiZD3k+HubYf2J94e7DbeHs7fuCPW5Qg==",
- "optional": true,
- "dependencies": {
- "tslib": "^2.3.1"
- }
- },
- "node_modules/@aws-sdk/util-body-length-browser/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/util-body-length-node": {
- "version": "3.208.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-body-length-node/-/util-body-length-node-3.208.0.tgz",
- "integrity": "sha512-3zj50e5g7t/MQf53SsuuSf0hEELzMtD8RX8C76f12OSRo2Bca4FLLYHe0TZbxcfQHom8/hOaeZEyTyMogMglqg==",
- "optional": true,
- "dependencies": {
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/util-body-length-node/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/util-buffer-from": {
- "version": "3.208.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-buffer-from/-/util-buffer-from-3.208.0.tgz",
- "integrity": "sha512-7L0XUixNEFcLUGPeBF35enCvB9Xl+K6SQsmbrPk1P3mlV9mguWSDQqbOBwY1Ir0OVbD6H/ZOQU7hI/9RtRI0Zw==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/is-array-buffer": "3.201.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/util-buffer-from/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/util-config-provider": {
- "version": "3.208.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-config-provider/-/util-config-provider-3.208.0.tgz",
- "integrity": "sha512-DSRqwrERUsT34ug+anlMBIFooBEGwM8GejC7q00Y/9IPrQy50KnG5PW2NiTjuLKNi7pdEOlwTSEocJE15eDZIg==",
- "optional": true,
- "dependencies": {
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/util-config-provider/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/util-defaults-mode-browser": {
- "version": "3.279.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-defaults-mode-browser/-/util-defaults-mode-browser-3.279.0.tgz",
- "integrity": "sha512-RnchYRrpapTT5Hu23LOfk6e8RMVq0kUzho6xA6TJj1a4uGxkcRMvgzPipCq1P5uHu0mrkQBg9pGPEVNOUs38/Q==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "bowser": "^2.11.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">= 10.0.0"
- }
- },
- "node_modules/@aws-sdk/util-defaults-mode-browser/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/util-defaults-mode-node": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-defaults-mode-node/-/util-defaults-mode-node-3.282.0.tgz",
- "integrity": "sha512-D1BlFoA7ZMeK2diDUWFx1xBFrSaJuBZMRBuWbnbT9AnRYNCsASZ8DRU1KkZ8LuFQIwmZz94P9q683emYnZBhiw==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/config-resolver": "3.282.0",
- "@aws-sdk/credential-provider-imds": "3.272.0",
- "@aws-sdk/node-config-provider": "3.272.0",
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">= 10.0.0"
- }
- },
- "node_modules/@aws-sdk/util-defaults-mode-node/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/util-endpoints": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.272.0.tgz",
- "integrity": "sha512-c4MPUaJt2G6gGpoiwIOqDfUa98c1J63RpYvf/spQEKOtC/tF5Gfqlxuq8FnAl5lHnrqj1B9ZXLLxFhHtDR0IiQ==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/util-endpoints/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/util-hex-encoding": {
- "version": "3.201.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-hex-encoding/-/util-hex-encoding-3.201.0.tgz",
- "integrity": "sha512-7t1vR1pVxKx0motd3X9rI3m/xNp78p3sHtP5yo4NP4ARpxyJ0fokBomY8ScaH2D/B+U5o9ARxldJUdMqyBlJcA==",
- "optional": true,
- "dependencies": {
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/util-hex-encoding/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/util-locate-window": {
- "version": "3.208.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.208.0.tgz",
- "integrity": "sha512-iua1A2+P7JJEDHVgvXrRJSvsnzG7stYSGQnBVphIUlemwl6nN5D+QrgbjECtrbxRz8asYFHSzhdhECqN+tFiBg==",
- "optional": true,
- "dependencies": {
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/util-locate-window/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/util-middleware": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-middleware/-/util-middleware-3.272.0.tgz",
- "integrity": "sha512-Abw8m30arbwxqmeMMha5J11ESpHUNmCeSqSzE8/C4B8jZQtHY4kq7f+upzcNIQ11lsd+uzBEzNG3+dDRi0XOJQ==",
- "optional": true,
- "dependencies": {
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/util-middleware/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/util-retry": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-retry/-/util-retry-3.272.0.tgz",
- "integrity": "sha512-Ngha5414LR4gRHURVKC9ZYXsEJhMkm+SJ+44wlzOhavglfdcKKPUsibz5cKY1jpUV7oKECwaxHWpBB8r6h+hOg==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/service-error-classification": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">= 14.0.0"
- }
- },
- "node_modules/@aws-sdk/util-retry/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/util-uri-escape": {
- "version": "3.201.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-uri-escape/-/util-uri-escape-3.201.0.tgz",
- "integrity": "sha512-TeTWbGx4LU2c5rx0obHeDFeO9HvwYwQtMh1yniBz00pQb6Qt6YVOETVQikRZ+XRQwEyCg/dA375UplIpiy54mA==",
- "optional": true,
- "dependencies": {
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/util-uri-escape/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/util-user-agent-browser": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.282.0.tgz",
- "integrity": "sha512-Z639oyTa5fZfyi4Xr64+eiAwBCxfpe9Op4Vhnr1z/RwonQM/qywydv6Ttpeq1q5uQ0nG4wTkOMpfh39g+VqIgw==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/types": "3.272.0",
- "bowser": "^2.11.0",
- "tslib": "^2.3.1"
- }
- },
- "node_modules/@aws-sdk/util-user-agent-browser/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/util-user-agent-node": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.282.0.tgz",
- "integrity": "sha512-GSOdWNmzEd554wR9HBrgeYptKBOybveVwUkd6ws+YTdCOz4xD5Gga+I5JomKkcMEUVdBrJnYVUtq7ZsJy2f11w==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/node-config-provider": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "peerDependencies": {
- "aws-crt": ">=1.0.0"
- },
- "peerDependenciesMeta": {
- "aws-crt": {
- "optional": true
- }
- }
- },
- "node_modules/@aws-sdk/util-user-agent-node/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/util-utf8": {
- "version": "3.254.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8/-/util-utf8-3.254.0.tgz",
- "integrity": "sha512-14Kso/eIt5/qfIBmhEL9L1IfyUqswjSTqO2mY7KOzUZ9SZbwn3rpxmtkhmATkRjD7XIlLKaxBkI7tU9Zjzj8Kw==",
- "optional": true,
- "dependencies": {
- "@aws-sdk/util-buffer-from": "3.208.0",
- "tslib": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@aws-sdk/util-utf8-browser": {
- "version": "3.259.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.259.0.tgz",
- "integrity": "sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==",
- "optional": true,
- "dependencies": {
- "tslib": "^2.3.1"
- }
- },
- "node_modules/@aws-sdk/util-utf8-browser/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@aws-sdk/util-utf8/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- },
- "node_modules/@dqbd/tiktoken": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/@dqbd/tiktoken/-/tiktoken-0.4.0.tgz",
- "integrity": "sha512-iaHgmwKAOqowBFZKxelyszoeGLoNw62eOULcmyme1aA1Ymr3JgYl0V7jwpuUm7fksalycZajx3loFn9TRUaviw=="
- },
- "node_modules/@fastify/ajv-compiler": {
- "version": "3.5.0",
- "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.5.0.tgz",
- "integrity": "sha512-ebbEtlI7dxXF5ziNdr05mOY8NnDiPB1XvAlLHctRt/Rc+C3LCOVW5imUVX+mhvUhnNzmPBHewUkOFgGlCxgdAA==",
- "dependencies": {
- "ajv": "^8.11.0",
- "ajv-formats": "^2.1.1",
- "fast-uri": "^2.0.0"
- }
- },
- "node_modules/@fastify/cors": {
- "version": "8.2.0",
- "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.2.0.tgz",
- "integrity": "sha512-qDgwpmg6C4D0D3nh8MTMuRXWyEwPnDZDBODaJv90FP2o9ukbahJByW4FtrM5Bpod5KbTf1oIExBmpItbUTQmHg==",
- "dependencies": {
- "fastify-plugin": "^4.0.0",
- "mnemonist": "0.39.5"
- }
- },
- "node_modules/@fastify/deepmerge": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-1.3.0.tgz",
- "integrity": "sha512-J8TOSBq3SoZbDhM9+R/u77hP93gz/rajSA+K2kGyijPpORPWUXHUpTaleoj+92As0S9uPRP7Oi8IqMf0u+ro6A=="
- },
- "node_modules/@fastify/error": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.2.0.tgz",
- "integrity": "sha512-KAfcLa+CnknwVi5fWogrLXgidLic+GXnLjijXdpl8pvkvbXU5BGa37iZO9FGvsh9ZL4y+oFi5cbHBm5UOG+dmQ=="
- },
- "node_modules/@fastify/fast-json-stringify-compiler": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.2.0.tgz",
- "integrity": "sha512-ypZynRvXA3dibfPykQN3RB5wBdEUgSGgny8Qc6k163wYPLD4mEGEDkACp+00YmqkGvIm8D/xYoHajwyEdWD/eg==",
- "dependencies": {
- "fast-json-stringify": "^5.0.0"
- }
- },
- "node_modules/@keyv/mongo": {
- "version": "2.1.8",
- "resolved": "https://registry.npmjs.org/@keyv/mongo/-/mongo-2.1.8.tgz",
- "integrity": "sha512-IOFKS9Y10c42NCaoD/6OKmqz7FMCm/VbMbrip7ma8tBvdWcPhDkkPV3ZpLgGsGw39RePzzKO6FQ89xs0+BFCKg==",
- "dependencies": {
- "mongodb": "^4.5.0",
- "pify": "^5.0.0"
- }
- },
- "node_modules/@types/node": {
- "version": "18.14.6",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.6.tgz",
- "integrity": "sha512-93+VvleD3mXwlLI/xASjw0FzKcwzl3OdTCzm1LaRfqgS21gfFtK3zDXM5Op9TeeMsJVOaJ2VRDpT9q4Y3d0AvA=="
- },
- "node_modules/@types/webidl-conversions": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
- "integrity": "sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog=="
- },
- "node_modules/@types/whatwg-url": {
- "version": "8.2.2",
- "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz",
- "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==",
- "dependencies": {
- "@types/node": "*",
- "@types/webidl-conversions": "*"
- }
- },
- "node_modules/@vscode/vscode-languagedetection": {
- "version": "1.0.22",
- "resolved": "https://registry.npmjs.org/@vscode/vscode-languagedetection/-/vscode-languagedetection-1.0.22.tgz",
- "integrity": "sha512-rQ/BgMyLuIXSmbA0MSkIPHtcOw14QkeDbAq19sjvaS9LTRr905yij0S8lsyqN5JgOsbtIx7pAcyOxFMzPmqhZQ==",
- "bin": {
- "vscode-languagedetection": "cli/index.js"
- }
- },
- "node_modules/@waylaidwanderer/chatgpt-api": {
- "version": "1.28.2",
- "resolved": "https://registry.npmjs.org/@waylaidwanderer/chatgpt-api/-/chatgpt-api-1.28.2.tgz",
- "integrity": "sha512-efNvZr8uosiYD69zFq50OPM36s+tyRMixlHpwDzn2q9UuZrdHC++kmm23OAnDxv3/+vA4UwCsZXn+92c35NHBQ==",
- "dependencies": {
- "@dqbd/tiktoken": "^0.4.0",
- "@fastify/cors": "^8.2.0",
- "@waylaidwanderer/fastify-sse-v2": "^3.1.0",
- "@waylaidwanderer/fetch-event-source": "^3.0.1",
- "boxen": "^7.0.1",
- "clipboardy": "^3.0.0",
- "dotenv": "^16.0.3",
- "fastify": "^4.11.0",
- "fetch-undici": "^3.0.1",
- "https-proxy-agent": "^5.0.1",
- "inquirer": "^9.1.4",
- "inquirer-autocomplete-prompt": "^3.0.0",
- "keyv": "^4.5.2",
- "keyv-file": "^0.2.0",
- "ora": "^6.1.2",
- "undici": "^5.20.0",
- "ws": "^8.12.0"
- },
- "bin": {
- "chatgpt-api": "bin/server.js",
- "chatgpt-cli": "bin/cli.js"
- }
- },
- "node_modules/@waylaidwanderer/fastify-sse-v2": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/@waylaidwanderer/fastify-sse-v2/-/fastify-sse-v2-3.1.0.tgz",
- "integrity": "sha512-R6/VT14+iGZmyp7Jih7FYZuWr0B0gJ9uym1xoVPlKjZBngzFS2bL8yvZyEIPbMrTjrC8syZY2z2WuMHsipRfpw==",
- "dependencies": {
- "fastify-plugin": "^4.3.0",
- "it-pushable": "^1.4.2",
- "it-to-stream": "^1.0.0"
- },
- "peerDependencies": {
- "fastify": ">=4"
- }
- },
- "node_modules/@waylaidwanderer/fetch-event-source": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/@waylaidwanderer/fetch-event-source/-/fetch-event-source-3.0.1.tgz",
- "integrity": "sha512-gkc7vmBW9uulRj7tY30/1D8iBrpcgphBpI+e7LP744x/hAzaQxUuyF+n4O5dctKx+dE3i4BFuCWMEz9fAx2jlQ==",
- "engines": {
- "node": ">=16.15"
- }
- },
- "node_modules/abbrev": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
- "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
- "dev": true
- },
- "node_modules/abort-controller": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
- "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
- "dependencies": {
- "event-target-shim": "^5.0.0"
- },
- "engines": {
- "node": ">=6.5"
- }
- },
- "node_modules/abstract-logging": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
- "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="
- },
- "node_modules/accepts": {
- "version": "1.3.8",
- "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
- "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
- "dependencies": {
- "mime-types": "~2.1.34",
- "negotiator": "0.6.3"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/agent-base": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
- "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
- "dependencies": {
- "debug": "4"
- },
- "engines": {
- "node": ">= 6.0.0"
- }
- },
- "node_modules/ajv": {
- "version": "8.12.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
- "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
- "dependencies": {
- "fast-deep-equal": "^3.1.1",
- "json-schema-traverse": "^1.0.0",
- "require-from-string": "^2.0.2",
- "uri-js": "^4.2.2"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
- }
- },
- "node_modules/ajv-formats": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
- "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
- "dependencies": {
- "ajv": "^8.0.0"
- },
- "peerDependencies": {
- "ajv": "^8.0.0"
- },
- "peerDependenciesMeta": {
- "ajv": {
- "optional": true
- }
- }
- },
- "node_modules/ansi-align": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz",
- "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==",
- "dependencies": {
- "string-width": "^4.1.0"
- }
- },
- "node_modules/ansi-align/node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/ansi-align/node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
- },
- "node_modules/ansi-align/node_modules/string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/ansi-align/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/ansi-escapes": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.0.0.tgz",
- "integrity": "sha512-IG23inYII3dWlU2EyiAiGj6Bwal5GzsgPMwjYGvc1HPE2dgbj4ZB5ToWBKSquKw74nB3TIuOwaI6/jSULzfgrw==",
- "dependencies": {
- "type-fest": "^3.0.0"
- },
- "engines": {
- "node": ">=14.16"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/ansi-escapes/node_modules/type-fest": {
- "version": "3.6.1",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.6.1.tgz",
- "integrity": "sha512-htXWckxlT6U4+ilVgweNliPqlsVSSucbxVexRYllyMVJDtf5rTjv6kF/s+qAd4QSL1BZcnJPEJavYBPQiWuZDA==",
- "engines": {
- "node": ">=14.16"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/ansi-regex": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
- "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-regex?sponsor=1"
- }
- },
- "node_modules/ansi-styles": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
- "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/anymatch": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
- "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
- "dev": true,
- "dependencies": {
- "normalize-path": "^3.0.0",
- "picomatch": "^2.0.4"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/arch": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz",
- "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ]
- },
- "node_modules/archy": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz",
- "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw=="
- },
- "node_modules/array-flatten": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
- "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
- },
- "node_modules/asynckit": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
- "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
- },
- "node_modules/atomic-sleep": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
- "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
- "engines": {
- "node": ">=8.0.0"
- }
- },
- "node_modules/avvio": {
- "version": "8.2.1",
- "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.2.1.tgz",
- "integrity": "sha512-TAlMYvOuwGyLK3PfBb5WKBXZmXz2fVCgv23d6zZFdle/q3gPjmxBaeuC0pY0Dzs5PWMSgfqqEZkrye19GlDTgw==",
- "dependencies": {
- "archy": "^1.0.0",
- "debug": "^4.0.0",
- "fastq": "^1.6.1"
- }
- },
- "node_modules/axios": {
- "version": "0.26.1",
- "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz",
- "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==",
- "dependencies": {
- "follow-redirects": "^1.14.8"
- }
- },
- "node_modules/balanced-match": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "dev": true
- },
- "node_modules/base64-js": {
- "version": "1.5.1",
- "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
- "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ]
- },
- "node_modules/binary-extensions": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
- "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/bl": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz",
- "integrity": "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==",
- "dependencies": {
- "buffer": "^6.0.3",
- "inherits": "^2.0.4",
- "readable-stream": "^3.4.0"
- }
- },
- "node_modules/body-parser": {
- "version": "1.20.1",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz",
- "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==",
- "dependencies": {
- "bytes": "3.1.2",
- "content-type": "~1.0.4",
- "debug": "2.6.9",
- "depd": "2.0.0",
- "destroy": "1.2.0",
- "http-errors": "2.0.0",
- "iconv-lite": "0.4.24",
- "on-finished": "2.4.1",
- "qs": "6.11.0",
- "raw-body": "2.5.1",
- "type-is": "~1.6.18",
- "unpipe": "1.0.0"
- },
- "engines": {
- "node": ">= 0.8",
- "npm": "1.2.8000 || >= 1.4.16"
- }
- },
- "node_modules/body-parser/node_modules/debug": {
- "version": "2.6.9",
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dependencies": {
- "ms": "2.0.0"
- }
- },
- "node_modules/body-parser/node_modules/ms": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
- },
- "node_modules/bowser": {
- "version": "2.11.0",
- "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz",
- "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==",
- "optional": true
- },
- "node_modules/boxen": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.2.tgz",
- "integrity": "sha512-1Z4UJabXUP1/R9rLpoU3O2lEMnG3pPLAs/ZD2lF3t2q7qD5lM8rqbtnvtvm4N0wEyNlE+9yZVTVAGmd1V5jabg==",
- "dependencies": {
- "ansi-align": "^3.0.1",
- "camelcase": "^7.0.0",
- "chalk": "^5.0.1",
- "cli-boxes": "^3.0.0",
- "string-width": "^5.1.2",
- "type-fest": "^2.13.0",
- "widest-line": "^4.0.1",
- "wrap-ansi": "^8.0.1"
- },
- "engines": {
- "node": ">=14.16"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
- "dev": true,
- "dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
- "node_modules/braces": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
- "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
- "dev": true,
- "dependencies": {
- "fill-range": "^7.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/bson": {
- "version": "4.7.2",
- "resolved": "https://registry.npmjs.org/bson/-/bson-4.7.2.tgz",
- "integrity": "sha512-Ry9wCtIZ5kGqkJoi6aD8KjxFZEx78guTQDnpXWiNthsxzrxAK/i8E6pCHAIZTbaEFWcOCvbecMukfK7XUvyLpQ==",
- "dependencies": {
- "buffer": "^5.6.0"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/bson/node_modules/buffer": {
- "version": "5.7.1",
- "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
- "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "dependencies": {
- "base64-js": "^1.3.1",
- "ieee754": "^1.1.13"
- }
- },
- "node_modules/buffer": {
- "version": "6.0.3",
- "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
- "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "dependencies": {
- "base64-js": "^1.3.1",
- "ieee754": "^1.2.1"
- }
- },
- "node_modules/busboy": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
- "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
- "dependencies": {
- "streamsearch": "^1.1.0"
- },
- "engines": {
- "node": ">=10.16.0"
- }
- },
- "node_modules/bytes": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
- "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/call-bind": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
- "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
- "dependencies": {
- "function-bind": "^1.1.1",
- "get-intrinsic": "^1.0.2"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/camelcase": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz",
- "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==",
- "engines": {
- "node": ">=14.16"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/chalk": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.2.0.tgz",
- "integrity": "sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA==",
- "engines": {
- "node": "^12.17.0 || ^14.13 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/chalk/chalk?sponsor=1"
- }
- },
- "node_modules/chardet": {
- "version": "0.7.0",
- "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
- "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="
- },
- "node_modules/chokidar": {
- "version": "3.5.3",
- "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
- "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
- "dev": true,
- "funding": [
- {
- "type": "individual",
- "url": "https://paulmillr.com/funding/"
- }
- ],
- "dependencies": {
- "anymatch": "~3.1.2",
- "braces": "~3.0.2",
- "glob-parent": "~5.1.2",
- "is-binary-path": "~2.1.0",
- "is-glob": "~4.0.1",
- "normalize-path": "~3.0.0",
- "readdirp": "~3.6.0"
- },
- "engines": {
- "node": ">= 8.10.0"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.2"
- }
- },
- "node_modules/cli-boxes": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
- "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/cli-cursor": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz",
- "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==",
- "dependencies": {
- "restore-cursor": "^4.0.0"
- },
- "engines": {
- "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/cli-spinners": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.7.0.tgz",
- "integrity": "sha512-qu3pN8Y3qHNgE2AFweciB1IfMnmZ/fsNTEE+NOFjmGB2F/7rLhnhzppvpCnN4FovtP26k8lHyy9ptEbNwWFLzw==",
- "engines": {
- "node": ">=6"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/cli-width": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.0.0.tgz",
- "integrity": "sha512-ZksGS2xpa/bYkNzN3BAw1wEjsLV/ZKOf/CCrJ/QOBsxx6fOARIkwTutxp1XIOIohi6HKmOFjMoK/XaqDVUpEEw==",
- "engines": {
- "node": ">= 12"
- }
- },
- "node_modules/clipboardy": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz",
- "integrity": "sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==",
- "dependencies": {
- "arch": "^2.2.0",
- "execa": "^5.1.1",
- "is-wsl": "^2.2.0"
- },
- "engines": {
- "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/clone": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
- "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==",
- "engines": {
- "node": ">=0.8"
- }
- },
- "node_modules/combined-stream": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
- "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
- "dependencies": {
- "delayed-stream": "~1.0.0"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/concat-map": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
- "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
- "dev": true
- },
- "node_modules/content-disposition": {
- "version": "0.5.4",
- "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
- "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
- "dependencies": {
- "safe-buffer": "5.2.1"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/content-type": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
- "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/cookie": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
- "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/cookie-signature": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
- "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
- },
- "node_modules/cors": {
- "version": "2.8.5",
- "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
- "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
- "dependencies": {
- "object-assign": "^4",
- "vary": "^1"
- },
- "engines": {
- "node": ">= 0.10"
- }
- },
- "node_modules/cross-spawn": {
- "version": "7.0.3",
- "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
- "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
- "dependencies": {
- "path-key": "^3.1.0",
- "shebang-command": "^2.0.0",
- "which": "^2.0.1"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/debug": {
- "version": "4.3.4",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
- "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
- "dependencies": {
- "ms": "2.1.2"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/defaults": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
- "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==",
- "dependencies": {
- "clone": "^1.0.2"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/delayed-stream": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
- "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/depd": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
- "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/destroy": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
- "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
- "engines": {
- "node": ">= 0.8",
- "npm": "1.2.8000 || >= 1.4.16"
- }
- },
- "node_modules/dotenv": {
- "version": "16.0.3",
- "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz",
- "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==",
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/eastasianwidth": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
- "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
- },
- "node_modules/ee-first": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
- "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
- },
- "node_modules/emoji-regex": {
- "version": "9.2.2",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
- "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
- },
- "node_modules/encodeurl": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
- "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/escape-html": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
- "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
- },
- "node_modules/escape-string-regexp": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
- "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/etag": {
- "version": "1.8.1",
- "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
- "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/event-target-shim": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
- "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/events": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
- "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
- "engines": {
- "node": ">=0.8.x"
- }
- },
- "node_modules/execa": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
- "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
- "dependencies": {
- "cross-spawn": "^7.0.3",
- "get-stream": "^6.0.0",
- "human-signals": "^2.1.0",
- "is-stream": "^2.0.0",
- "merge-stream": "^2.0.0",
- "npm-run-path": "^4.0.1",
- "onetime": "^5.1.2",
- "signal-exit": "^3.0.3",
- "strip-final-newline": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sindresorhus/execa?sponsor=1"
- }
- },
- "node_modules/express": {
- "version": "4.18.2",
- "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
- "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==",
- "dependencies": {
- "accepts": "~1.3.8",
- "array-flatten": "1.1.1",
- "body-parser": "1.20.1",
- "content-disposition": "0.5.4",
- "content-type": "~1.0.4",
- "cookie": "0.5.0",
- "cookie-signature": "1.0.6",
- "debug": "2.6.9",
- "depd": "2.0.0",
- "encodeurl": "~1.0.2",
- "escape-html": "~1.0.3",
- "etag": "~1.8.1",
- "finalhandler": "1.2.0",
- "fresh": "0.5.2",
- "http-errors": "2.0.0",
- "merge-descriptors": "1.0.1",
- "methods": "~1.1.2",
- "on-finished": "2.4.1",
- "parseurl": "~1.3.3",
- "path-to-regexp": "0.1.7",
- "proxy-addr": "~2.0.7",
- "qs": "6.11.0",
- "range-parser": "~1.2.1",
- "safe-buffer": "5.2.1",
- "send": "0.18.0",
- "serve-static": "1.15.0",
- "setprototypeof": "1.2.0",
- "statuses": "2.0.1",
- "type-is": "~1.6.18",
- "utils-merge": "1.0.1",
- "vary": "~1.1.2"
- },
- "engines": {
- "node": ">= 0.10.0"
- }
- },
- "node_modules/express/node_modules/debug": {
- "version": "2.6.9",
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dependencies": {
- "ms": "2.0.0"
- }
- },
- "node_modules/express/node_modules/ms": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
- },
- "node_modules/external-editor": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
- "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==",
- "dependencies": {
- "chardet": "^0.7.0",
- "iconv-lite": "^0.4.24",
- "tmp": "^0.0.33"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/fast-content-type-parse": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.0.0.tgz",
- "integrity": "sha512-Xbc4XcysUXcsP5aHUU7Nq3OwvHq97C+WnbkeIefpeYLX+ryzFJlU6OStFJhs6Ol0LkUGpcK+wL0JwfM+FCU5IA=="
- },
- "node_modules/fast-decode-uri-component": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
- "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="
- },
- "node_modules/fast-deep-equal": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
- "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
- },
- "node_modules/fast-fifo": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.1.0.tgz",
- "integrity": "sha512-Kl29QoNbNvn4nhDsLYjyIAaIqaJB6rBx5p3sL9VjaefJ+eMFBWVZiaoguaoZfzEKr5RhAti0UgM8703akGPJ6g=="
- },
- "node_modules/fast-json-stringify": {
- "version": "5.6.2",
- "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.6.2.tgz",
- "integrity": "sha512-F6xkRrXvtGbAiDSEI5Rk7qk2P63Y9kc8bO6Dnsd3Rt6sBNr2QxNFWs0JbKftgiyOfGxnJaRoHe4SizCTqeAyrA==",
- "dependencies": {
- "@fastify/deepmerge": "^1.0.0",
- "ajv": "^8.10.0",
- "ajv-formats": "^2.1.1",
- "fast-deep-equal": "^3.1.3",
- "fast-uri": "^2.1.0",
- "rfdc": "^1.2.0"
- }
- },
- "node_modules/fast-querystring": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.1.tgz",
- "integrity": "sha512-qR2r+e3HvhEFmpdHMv//U8FnFlnYjaC6QKDuaXALDkw2kvHO8WDjxH+f/rHGR4Me4pnk8p9JAkRNTjYHAKRn2Q==",
- "dependencies": {
- "fast-decode-uri-component": "^1.0.1"
- }
- },
- "node_modules/fast-redact": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.1.2.tgz",
- "integrity": "sha512-+0em+Iya9fKGfEQGcd62Yv6onjBmmhV1uh86XVfOU8VwAe6kaFdQCWI9s0/Nnugx5Vd9tdbZ7e6gE2tR9dzXdw==",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/fast-uri": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.2.0.tgz",
- "integrity": "sha512-cIusKBIt/R/oI6z/1nyfe2FvGKVTohVRfvkOhvx0nCEW+xf5NoCXjAHcWp93uOUBchzYcsvPlrapAdX1uW+YGg=="
- },
- "node_modules/fast-xml-parser": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.1.2.tgz",
- "integrity": "sha512-CDYeykkle1LiA/uqQyNwYpFbyF6Axec6YapmpUP+/RHWIoR1zKjocdvNaTsxCxZzQ6v9MLXaSYm9Qq0thv0DHg==",
- "optional": true,
- "dependencies": {
- "strnum": "^1.0.5"
- },
- "bin": {
- "fxparser": "src/cli/cli.js"
- },
- "funding": {
- "type": "paypal",
- "url": "https://paypal.me/naturalintelligence"
- }
- },
- "node_modules/fastify": {
- "version": "4.14.1",
- "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.14.1.tgz",
- "integrity": "sha512-yjrDeXe77j9gRlSV2UJry8mcFWbD0NQ5JYjnPi4tkFjHZVaG3/BD5wxOmRzGnHPC0YvaBJ0XWrIfFPl2IHRa1w==",
- "dependencies": {
- "@fastify/ajv-compiler": "^3.5.0",
- "@fastify/error": "^3.0.0",
- "@fastify/fast-json-stringify-compiler": "^4.1.0",
- "abstract-logging": "^2.0.1",
- "avvio": "^8.2.0",
- "fast-content-type-parse": "^1.0.0",
- "find-my-way": "^7.3.0",
- "light-my-request": "^5.6.1",
- "pino": "^8.5.0",
- "process-warning": "^2.0.0",
- "proxy-addr": "^2.0.7",
- "rfdc": "^1.3.0",
- "secure-json-parse": "^2.5.0",
- "semver": "^7.3.7",
- "tiny-lru": "^10.0.0"
- }
- },
- "node_modules/fastify-plugin": {
- "version": "4.5.0",
- "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.0.tgz",
- "integrity": "sha512-79ak0JxddO0utAXAQ5ccKhvs6vX2MGyHHMMsmZkBANrq3hXc1CHzvNPHOcvTsVMEPl5I+NT+RO4YKMGehOfSIg=="
- },
- "node_modules/fastq": {
- "version": "1.15.0",
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
- "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==",
- "dependencies": {
- "reusify": "^1.0.4"
- }
- },
- "node_modules/fetch-undici": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/fetch-undici/-/fetch-undici-3.0.1.tgz",
- "integrity": "sha512-UHHu1HqW22ZhK6C/1Zmjf7mQpOwPwLYZ+xcsOgpzistONU8QqvCop6Od29p/kw1GUVoq2Ihu6ItpKLtlojx4FQ==",
- "dependencies": {
- "undici": "^5.0.0"
- },
- "engines": {
- "node": ">=16"
- },
- "funding": {
- "type": "individual",
- "url": "https://github.com/sponsors/bcomnes"
- }
- },
- "node_modules/figures": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/figures/-/figures-5.0.0.tgz",
- "integrity": "sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==",
- "dependencies": {
- "escape-string-regexp": "^5.0.0",
- "is-unicode-supported": "^1.2.0"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/fill-range": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
- "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
- "dev": true,
- "dependencies": {
- "to-regex-range": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/finalhandler": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz",
- "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==",
- "dependencies": {
- "debug": "2.6.9",
- "encodeurl": "~1.0.2",
- "escape-html": "~1.0.3",
- "on-finished": "2.4.1",
- "parseurl": "~1.3.3",
- "statuses": "2.0.1",
- "unpipe": "~1.0.0"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/finalhandler/node_modules/debug": {
- "version": "2.6.9",
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dependencies": {
- "ms": "2.0.0"
- }
- },
- "node_modules/finalhandler/node_modules/ms": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
- },
- "node_modules/find-my-way": {
- "version": "7.5.0",
- "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-7.5.0.tgz",
- "integrity": "sha512-3ehydSBhGcS0TtMA/BYEyMAKi9Sv0MqF8aqiMO5oGBXyCcSlyEJyfGWsbNxAx7BekTNWUwD1ttLJLURni2vmJg==",
- "dependencies": {
- "fast-deep-equal": "^3.1.3",
- "fast-querystring": "^1.0.0",
- "safe-regex2": "^2.0.0"
- },
- "engines": {
- "node": ">=14"
- }
- },
- "node_modules/follow-redirects": {
- "version": "1.15.2",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
- "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
- "funding": [
- {
- "type": "individual",
- "url": "https://github.com/sponsors/RubenVerborgh"
- }
- ],
- "engines": {
- "node": ">=4.0"
- },
- "peerDependenciesMeta": {
- "debug": {
- "optional": true
- }
- }
- },
- "node_modules/form-data": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
- "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
- "dependencies": {
- "asynckit": "^0.4.0",
- "combined-stream": "^1.0.8",
- "mime-types": "^2.1.12"
- },
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/forwarded": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
- "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/fresh": {
- "version": "0.5.2",
- "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
- "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/fs-extra": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.3.tgz",
- "integrity": "sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==",
- "dependencies": {
- "graceful-fs": "^4.1.2",
- "jsonfile": "^4.0.0",
- "universalify": "^0.1.0"
- }
- },
- "node_modules/fsevents": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
- "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "dev": true,
- "hasInstallScript": true,
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/function-bind": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
- "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
- },
- "node_modules/get-intrinsic": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz",
- "integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==",
- "dependencies": {
- "function-bind": "^1.1.1",
- "has": "^1.0.3",
- "has-symbols": "^1.0.3"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/get-iterator": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/get-iterator/-/get-iterator-1.0.2.tgz",
- "integrity": "sha512-v+dm9bNVfOYsY1OrhaCrmyOcYoSeVvbt+hHZ0Au+T+p1y+0Uyj9aMaGIeUTT6xdpRbWzDeYKvfOslPhggQMcsg=="
- },
- "node_modules/get-stream": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
- "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/glob-parent": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
- "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "dev": true,
- "dependencies": {
- "is-glob": "^4.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/graceful-fs": {
- "version": "4.2.10",
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
- "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="
- },
- "node_modules/has": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
- "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
- "dependencies": {
- "function-bind": "^1.1.1"
- },
- "engines": {
- "node": ">= 0.4.0"
- }
- },
- "node_modules/has-flag": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
- "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
- "dev": true,
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/has-symbols": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
- "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/http-errors": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
- "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
- "dependencies": {
- "depd": "2.0.0",
- "inherits": "2.0.4",
- "setprototypeof": "1.2.0",
- "statuses": "2.0.1",
- "toidentifier": "1.0.1"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/https-proxy-agent": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
- "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
- "dependencies": {
- "agent-base": "6",
- "debug": "4"
- },
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/human-signals": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
- "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
- "engines": {
- "node": ">=10.17.0"
- }
- },
- "node_modules/iconv-lite": {
- "version": "0.4.24",
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
- "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
- "dependencies": {
- "safer-buffer": ">= 2.1.2 < 3"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/ieee754": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
- "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ]
- },
- "node_modules/ignore-by-default": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
- "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
- "dev": true
- },
- "node_modules/inherits": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
- "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
- },
- "node_modules/inquirer": {
- "version": "9.1.4",
- "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.1.4.tgz",
- "integrity": "sha512-9hiJxE5gkK/cM2d1mTEnuurGTAoHebbkX0BYl3h7iEg7FYfuNIom+nDfBCSWtvSnoSrWCeBxqqBZu26xdlJlXA==",
- "dependencies": {
- "ansi-escapes": "^6.0.0",
- "chalk": "^5.1.2",
- "cli-cursor": "^4.0.0",
- "cli-width": "^4.0.0",
- "external-editor": "^3.0.3",
- "figures": "^5.0.0",
- "lodash": "^4.17.21",
- "mute-stream": "0.0.8",
- "ora": "^6.1.2",
- "run-async": "^2.4.0",
- "rxjs": "^7.5.7",
- "string-width": "^5.1.2",
- "strip-ansi": "^7.0.1",
- "through": "^2.3.6",
- "wrap-ansi": "^8.0.1"
- },
- "engines": {
- "node": ">=12.0.0"
- }
- },
- "node_modules/inquirer-autocomplete-prompt": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/inquirer-autocomplete-prompt/-/inquirer-autocomplete-prompt-3.0.0.tgz",
- "integrity": "sha512-nsPWllBQB3qhvpVgV1UIJN4xo3yz7Qv8y1+zrNVpJUNPxtUZ7btCum/4UCAs5apPCe/FVhKH1V6Wx0cAwkreyg==",
- "dependencies": {
- "ansi-escapes": "^6.0.0",
- "figures": "^5.0.0",
- "picocolors": "^1.0.0",
- "run-async": "^2.4.1",
- "rxjs": "^7.5.6"
- },
- "engines": {
- "node": ">=12"
- },
- "peerDependencies": {
- "inquirer": "^9.1.0"
- }
- },
- "node_modules/ip": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz",
- "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ=="
- },
- "node_modules/ipaddr.js": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
- "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
- "engines": {
- "node": ">= 0.10"
- }
- },
- "node_modules/is-binary-path": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
- "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
- "dev": true,
- "dependencies": {
- "binary-extensions": "^2.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/is-docker": {
- "version": "2.2.1",
- "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
- "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
- "bin": {
- "is-docker": "cli.js"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/is-extglob": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
- "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
- "dev": true,
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/is-fullwidth-code-point": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
- "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/is-glob": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
- "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
- "dev": true,
- "dependencies": {
- "is-extglob": "^2.1.1"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/is-interactive": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz",
- "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/is-number": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
- "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
- "dev": true,
- "engines": {
- "node": ">=0.12.0"
- }
- },
- "node_modules/is-stream": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
- "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/is-unicode-supported": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz",
- "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/is-wsl": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
- "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
- "dependencies": {
- "is-docker": "^2.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/isexe": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
- "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
- },
- "node_modules/it-pushable": {
- "version": "1.4.2",
- "resolved": "https://registry.npmjs.org/it-pushable/-/it-pushable-1.4.2.tgz",
- "integrity": "sha512-vVPu0CGRsTI8eCfhMknA7KIBqqGFolbRx+1mbQ6XuZ7YCz995Qj7L4XUviwClFunisDq96FdxzF5FnAbw15afg==",
- "dependencies": {
- "fast-fifo": "^1.0.0"
- }
- },
- "node_modules/it-to-stream": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/it-to-stream/-/it-to-stream-1.0.0.tgz",
- "integrity": "sha512-pLULMZMAB/+vbdvbZtebC0nWBTbG581lk6w8P7DfIIIKUfa8FbY7Oi0FxZcFPbxvISs7A9E+cMpLDBc1XhpAOA==",
- "dependencies": {
- "buffer": "^6.0.3",
- "fast-fifo": "^1.0.0",
- "get-iterator": "^1.0.2",
- "p-defer": "^3.0.0",
- "p-fifo": "^1.0.0",
- "readable-stream": "^3.6.0"
- }
- },
- "node_modules/json-buffer": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
- "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="
- },
- "node_modules/json-schema-traverse": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
- "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
- },
- "node_modules/jsonfile": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
- "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
- }
- },
- "node_modules/kareem": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz",
- "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==",
- "engines": {
- "node": ">=12.0.0"
- }
- },
- "node_modules/keyv": {
- "version": "4.5.2",
- "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.2.tgz",
- "integrity": "sha512-5MHbFaKn8cNSmVW7BYnijeAVlE4cYA/SVkifVgrh7yotnfhKmjuXpDKjrABLnT0SfHWV21P8ow07OGfRrNDg8g==",
- "dependencies": {
- "json-buffer": "3.0.1"
- }
- },
- "node_modules/keyv-file": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/keyv-file/-/keyv-file-0.2.0.tgz",
- "integrity": "sha512-zUQ11eZRmilEUpV1gJSj8mBAHjyXpleQo1iCS0khb+GFRhiPfwavWgn4eDUKNlOyMZzmExnISl8HE1hNbim0gw==",
- "dependencies": {
- "debug": "^4.1.1",
- "fs-extra": "^4.0.1",
- "tslib": "^1.9.3"
- }
- },
- "node_modules/light-my-request": {
- "version": "5.9.1",
- "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.9.1.tgz",
- "integrity": "sha512-UT7pUk8jNCR1wR7w3iWfIjx32DiB2f3hFdQSOwy3/EPQ3n3VocyipUxcyRZR0ahoev+fky69uA+GejPa9KuHKg==",
- "dependencies": {
- "cookie": "^0.5.0",
- "process-warning": "^2.0.0",
- "set-cookie-parser": "^2.4.1"
- }
- },
- "node_modules/lodash": {
- "version": "4.17.21",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
- },
- "node_modules/log-symbols": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-5.1.0.tgz",
- "integrity": "sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==",
- "dependencies": {
- "chalk": "^5.0.0",
- "is-unicode-supported": "^1.1.0"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/lru-cache": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
- "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
- "dependencies": {
- "yallist": "^4.0.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/media-typer": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
- "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/memory-pager": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
- "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
- "optional": true
- },
- "node_modules/merge-descriptors": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
- "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w=="
- },
- "node_modules/merge-stream": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
- "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
- },
- "node_modules/methods": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
- "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/mime": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
- "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
- "bin": {
- "mime": "cli.js"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/mime-db": {
- "version": "1.52.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
- "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/mime-types": {
- "version": "2.1.35",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
- "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
- "dependencies": {
- "mime-db": "1.52.0"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/mimic-fn": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
- "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
- "dev": true,
- "dependencies": {
- "brace-expansion": "^1.1.7"
- },
- "engines": {
- "node": "*"
- }
- },
- "node_modules/mnemonist": {
- "version": "0.39.5",
- "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.5.tgz",
- "integrity": "sha512-FPUtkhtJ0efmEFGpU14x7jGbTB+s18LrzRL2KgoWz9YvcY3cPomz8tih01GbHwnGk/OmkOKfqd/RAQoc8Lm7DQ==",
- "dependencies": {
- "obliterator": "^2.0.1"
- }
- },
- "node_modules/mongodb": {
- "version": "4.14.0",
- "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.14.0.tgz",
- "integrity": "sha512-coGKkWXIBczZPr284tYKFLg+KbGPPLlSbdgfKAb6QqCFt5bo5VFZ50O3FFzsw4rnkqjwT6D8Qcoo9nshYKM7Mg==",
- "dependencies": {
- "bson": "^4.7.0",
- "mongodb-connection-string-url": "^2.5.4",
- "socks": "^2.7.1"
- },
- "engines": {
- "node": ">=12.9.0"
- },
- "optionalDependencies": {
- "@aws-sdk/credential-providers": "^3.186.0",
- "saslprep": "^1.0.3"
- }
- },
- "node_modules/mongodb-connection-string-url": {
- "version": "2.6.0",
- "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz",
- "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==",
- "dependencies": {
- "@types/whatwg-url": "^8.2.1",
- "whatwg-url": "^11.0.0"
- }
- },
- "node_modules/mongoose": {
- "version": "6.10.1",
- "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-6.10.1.tgz",
- "integrity": "sha512-QBXX27XXCEIi1zqimTBjlZsFFg2YzK9uQfuAIqG1AyIEeyKiiCaobD0Tst2ULwiX4PRB53VLRQvAqyu5TwvOZg==",
- "dependencies": {
- "bson": "^4.7.0",
- "kareem": "2.5.1",
- "mongodb": "4.14.0",
- "mpath": "0.9.0",
- "mquery": "4.0.3",
- "ms": "2.1.3",
- "sift": "16.0.1"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/mongoose"
- }
- },
- "node_modules/mongoose/node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
- },
- "node_modules/mpath": {
- "version": "0.9.0",
- "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
- "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==",
- "engines": {
- "node": ">=4.0.0"
- }
- },
- "node_modules/mquery": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/mquery/-/mquery-4.0.3.tgz",
- "integrity": "sha512-J5heI+P08I6VJ2Ky3+33IpCdAvlYGTSUjwTPxkAr8i8EoduPMBX2OY/wa3IKZIQl7MU4SbFk8ndgSKyB/cl1zA==",
- "dependencies": {
- "debug": "4.x"
- },
- "engines": {
- "node": ">=12.0.0"
- }
- },
- "node_modules/ms": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
- "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
- },
- "node_modules/mute-stream": {
- "version": "0.0.8",
- "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
- "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="
- },
- "node_modules/negotiator": {
- "version": "0.6.3",
- "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
- "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/nodemon": {
- "version": "2.0.21",
- "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.21.tgz",
- "integrity": "sha512-djN/n2549DUtY33S7o1djRCd7dEm0kBnj9c7S9XVXqRUbuggN1MZH/Nqa+5RFQr63Fbefq37nFXAE9VU86yL1A==",
- "dev": true,
- "dependencies": {
- "chokidar": "^3.5.2",
- "debug": "^3.2.7",
- "ignore-by-default": "^1.0.1",
- "minimatch": "^3.1.2",
- "pstree.remy": "^1.1.8",
- "semver": "^5.7.1",
- "simple-update-notifier": "^1.0.7",
- "supports-color": "^5.5.0",
- "touch": "^3.1.0",
- "undefsafe": "^2.0.5"
- },
- "bin": {
- "nodemon": "bin/nodemon.js"
- },
- "engines": {
- "node": ">=8.10.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/nodemon"
- }
- },
- "node_modules/nodemon/node_modules/debug": {
- "version": "3.2.7",
- "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
- "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
- "dev": true,
- "dependencies": {
- "ms": "^2.1.1"
- }
- },
- "node_modules/nodemon/node_modules/semver": {
- "version": "5.7.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
- "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
- "dev": true,
- "bin": {
- "semver": "bin/semver"
- }
- },
- "node_modules/nopt": {
- "version": "1.0.10",
- "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz",
- "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==",
- "dev": true,
- "dependencies": {
- "abbrev": "1"
- },
- "bin": {
- "nopt": "bin/nopt.js"
- },
- "engines": {
- "node": "*"
- }
- },
- "node_modules/normalize-path": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
- "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
- "dev": true,
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/npm-run-path": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
- "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
- "dependencies": {
- "path-key": "^3.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/object-assign": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
- "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/object-inspect": {
- "version": "1.12.3",
- "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz",
- "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/obliterator": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.4.tgz",
- "integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ=="
- },
- "node_modules/on-exit-leak-free": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.0.tgz",
- "integrity": "sha512-VuCaZZAjReZ3vUwgOB8LxAosIurDiAW0s13rI1YwmaP++jvcxP77AWoQvenZebpCA2m8WC1/EosPYPMjnRAp/w=="
- },
- "node_modules/on-finished": {
- "version": "2.4.1",
- "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
- "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
- "dependencies": {
- "ee-first": "1.1.1"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/onetime": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
- "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
- "dependencies": {
- "mimic-fn": "^2.1.0"
- },
- "engines": {
- "node": ">=6"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/openai": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/openai/-/openai-3.2.1.tgz",
- "integrity": "sha512-762C9BNlJPbjjlWZi4WYK9iM2tAVAv0uUp1UmI34vb0CN5T2mjB/qM6RYBmNKMh/dN9fC+bxqPwWJZUTWW052A==",
- "dependencies": {
- "axios": "^0.26.0",
- "form-data": "^4.0.0"
- }
- },
- "node_modules/ora": {
- "version": "6.1.2",
- "resolved": "https://registry.npmjs.org/ora/-/ora-6.1.2.tgz",
- "integrity": "sha512-EJQ3NiP5Xo94wJXIzAyOtSb0QEIAUu7m8t6UZ9krbz0vAJqr92JpcK/lEXg91q6B9pEGqrykkd2EQplnifDSBw==",
- "dependencies": {
- "bl": "^5.0.0",
- "chalk": "^5.0.0",
- "cli-cursor": "^4.0.0",
- "cli-spinners": "^2.6.1",
- "is-interactive": "^2.0.0",
- "is-unicode-supported": "^1.1.0",
- "log-symbols": "^5.1.0",
- "strip-ansi": "^7.0.1",
- "wcwidth": "^1.0.1"
- },
- "engines": {
- "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/os-tmpdir": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
- "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/p-defer": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-3.0.0.tgz",
- "integrity": "sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw==",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/p-fifo": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/p-fifo/-/p-fifo-1.0.0.tgz",
- "integrity": "sha512-IjoCxXW48tqdtDFz6fqo5q1UfFVjjVZe8TC1QRflvNUJtNfCUhxOUw6MOVZhDPjqhSzc26xKdugsO17gmzd5+A==",
- "dependencies": {
- "fast-fifo": "^1.0.0",
- "p-defer": "^3.0.0"
- }
- },
- "node_modules/parseurl": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
- "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/path": {
- "version": "0.12.7",
- "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz",
- "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==",
- "dev": true,
- "dependencies": {
- "process": "^0.11.1",
- "util": "^0.10.3"
- }
- },
- "node_modules/path-key": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
- "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/path-to-regexp": {
- "version": "0.1.7",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
- "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ=="
- },
- "node_modules/picocolors": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
- "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="
- },
- "node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
- "dev": true,
- "engines": {
- "node": ">=8.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/pify": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz",
- "integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/pino": {
- "version": "8.11.0",
- "resolved": "https://registry.npmjs.org/pino/-/pino-8.11.0.tgz",
- "integrity": "sha512-Z2eKSvlrl2rH8p5eveNUnTdd4AjJk8tAsLkHYZQKGHP4WTh2Gi1cOSOs3eWPqaj+niS3gj4UkoreoaWgF3ZWYg==",
- "dependencies": {
- "atomic-sleep": "^1.0.0",
- "fast-redact": "^3.1.1",
- "on-exit-leak-free": "^2.1.0",
- "pino-abstract-transport": "v1.0.0",
- "pino-std-serializers": "^6.0.0",
- "process-warning": "^2.0.0",
- "quick-format-unescaped": "^4.0.3",
- "real-require": "^0.2.0",
- "safe-stable-stringify": "^2.3.1",
- "sonic-boom": "^3.1.0",
- "thread-stream": "^2.0.0"
- },
- "bin": {
- "pino": "bin.js"
- }
- },
- "node_modules/pino-abstract-transport": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.0.0.tgz",
- "integrity": "sha512-c7vo5OpW4wIS42hUVcT5REsL8ZljsUfBjqV/e2sFxmFEFZiq1XLUp5EYLtuDH6PEHq9W1egWqRbnLUP5FuZmOA==",
- "dependencies": {
- "readable-stream": "^4.0.0",
- "split2": "^4.0.0"
- }
- },
- "node_modules/pino-abstract-transport/node_modules/readable-stream": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.3.0.tgz",
- "integrity": "sha512-MuEnA0lbSi7JS8XM+WNJlWZkHAAdm7gETHdFK//Q/mChGyj2akEFtdLZh32jSdkWGbRwCW9pn6g3LWDdDeZnBQ==",
- "dependencies": {
- "abort-controller": "^3.0.0",
- "buffer": "^6.0.3",
- "events": "^3.3.0",
- "process": "^0.11.10"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- }
- },
- "node_modules/pino-std-serializers": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-6.1.0.tgz",
- "integrity": "sha512-KO0m2f1HkrPe9S0ldjx7za9BJjeHqBku5Ch8JyxETxT8dEFGz1PwgrHaOQupVYitpzbFSYm7nnljxD8dik2c+g=="
- },
- "node_modules/process": {
- "version": "0.11.10",
- "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
- "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
- "engines": {
- "node": ">= 0.6.0"
- }
- },
- "node_modules/process-warning": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-2.1.0.tgz",
- "integrity": "sha512-9C20RLxrZU/rFnxWncDkuF6O999NdIf3E1ws4B0ZeY3sRVPzWBMsYDE2lxjxhiXxg464cQTgKUGm8/i6y2YGXg=="
- },
- "node_modules/proxy-addr": {
- "version": "2.0.7",
- "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
- "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
- "dependencies": {
- "forwarded": "0.2.0",
- "ipaddr.js": "1.9.1"
- },
- "engines": {
- "node": ">= 0.10"
- }
- },
- "node_modules/pstree.remy": {
- "version": "1.1.8",
- "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
- "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
- "dev": true
- },
- "node_modules/punycode": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz",
- "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/qs": {
- "version": "6.11.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
- "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==",
- "dependencies": {
- "side-channel": "^1.0.4"
- },
- "engines": {
- "node": ">=0.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/quick-format-unescaped": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
- "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
- },
- "node_modules/range-parser": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
- "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/raw-body": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
- "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==",
- "dependencies": {
- "bytes": "3.1.2",
- "http-errors": "2.0.0",
- "iconv-lite": "0.4.24",
- "unpipe": "1.0.0"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/readable-stream": {
- "version": "3.6.1",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.1.tgz",
- "integrity": "sha512-+rQmrWMYGA90yenhTYsLWAsLsqVC8osOw6PKE1HDYiO0gdPeKe/xDHNzIAIn4C91YQ6oenEhfYqqc1883qHbjQ==",
- "dependencies": {
- "inherits": "^2.0.3",
- "string_decoder": "^1.1.1",
- "util-deprecate": "^1.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/readdirp": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
- "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
- "dev": true,
- "dependencies": {
- "picomatch": "^2.2.1"
- },
- "engines": {
- "node": ">=8.10.0"
- }
- },
- "node_modules/real-require": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
- "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
- "engines": {
- "node": ">= 12.13.0"
- }
- },
- "node_modules/require-from-string": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
- "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/restore-cursor": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
- "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==",
- "dependencies": {
- "onetime": "^5.1.0",
- "signal-exit": "^3.0.2"
- },
- "engines": {
- "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/ret": {
- "version": "0.2.2",
- "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz",
- "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==",
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/reusify": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
- "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
- "engines": {
- "iojs": ">=1.0.0",
- "node": ">=0.10.0"
- }
- },
- "node_modules/rfdc": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz",
- "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA=="
- },
- "node_modules/run-async": {
- "version": "2.4.1",
- "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz",
- "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==",
- "engines": {
- "node": ">=0.12.0"
- }
- },
- "node_modules/rxjs": {
- "version": "7.8.0",
- "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.0.tgz",
- "integrity": "sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==",
- "dependencies": {
- "tslib": "^2.1.0"
- }
- },
- "node_modules/rxjs/node_modules/tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg=="
- },
- "node_modules/safe-buffer": {
- "version": "5.2.1",
- "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
- "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ]
- },
- "node_modules/safe-regex2": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-2.0.0.tgz",
- "integrity": "sha512-PaUSFsUaNNuKwkBijoAPHAK6/eM6VirvyPWlZ7BAQy4D+hCvh4B6lIG+nPdhbFfIbP+gTGBcrdsOaUs0F+ZBOQ==",
- "dependencies": {
- "ret": "~0.2.0"
- }
- },
- "node_modules/safe-stable-stringify": {
- "version": "2.4.2",
- "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.2.tgz",
- "integrity": "sha512-gMxvPJYhP0O9n2pvcfYfIuYgbledAOJFcqRThtPRmjscaipiwcwPPKLytpVzMkG2HAN87Qmo2d4PtGiri1dSLA==",
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/safer-buffer": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
- "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
- },
- "node_modules/saslprep": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz",
- "integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==",
- "optional": true,
- "dependencies": {
- "sparse-bitfield": "^3.0.3"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/secure-json-parse": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
- "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="
- },
- "node_modules/semver": {
- "version": "7.3.8",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
- "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
- "dependencies": {
- "lru-cache": "^6.0.0"
- },
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/send": {
- "version": "0.18.0",
- "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz",
- "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==",
- "dependencies": {
- "debug": "2.6.9",
- "depd": "2.0.0",
- "destroy": "1.2.0",
- "encodeurl": "~1.0.2",
- "escape-html": "~1.0.3",
- "etag": "~1.8.1",
- "fresh": "0.5.2",
- "http-errors": "2.0.0",
- "mime": "1.6.0",
- "ms": "2.1.3",
- "on-finished": "2.4.1",
- "range-parser": "~1.2.1",
- "statuses": "2.0.1"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/send/node_modules/debug": {
- "version": "2.6.9",
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dependencies": {
- "ms": "2.0.0"
- }
- },
- "node_modules/send/node_modules/debug/node_modules/ms": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
- },
- "node_modules/send/node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
- },
- "node_modules/serve-static": {
- "version": "1.15.0",
- "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz",
- "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==",
- "dependencies": {
- "encodeurl": "~1.0.2",
- "escape-html": "~1.0.3",
- "parseurl": "~1.3.3",
- "send": "0.18.0"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/set-cookie-parser": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.5.1.tgz",
- "integrity": "sha512-1jeBGaKNGdEq4FgIrORu/N570dwoPYio8lSoYLWmX7sQ//0JY08Xh9o5pBcgmHQ/MbsYp/aZnOe1s1lIsbLprQ=="
- },
- "node_modules/setprototypeof": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
- "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
- },
- "node_modules/shebang-command": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
- "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
- "dependencies": {
- "shebang-regex": "^3.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/shebang-regex": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
- "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/side-channel": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
- "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
- "dependencies": {
- "call-bind": "^1.0.0",
- "get-intrinsic": "^1.0.2",
- "object-inspect": "^1.9.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/sift": {
- "version": "16.0.1",
- "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz",
- "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ=="
- },
- "node_modules/signal-exit": {
- "version": "3.0.7",
- "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
- "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
- },
- "node_modules/simple-update-notifier": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz",
- "integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==",
- "dev": true,
- "dependencies": {
- "semver": "~7.0.0"
- },
- "engines": {
- "node": ">=8.10.0"
- }
- },
- "node_modules/simple-update-notifier/node_modules/semver": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz",
- "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==",
- "dev": true,
- "bin": {
- "semver": "bin/semver.js"
- }
- },
- "node_modules/smart-buffer": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
- "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
- "engines": {
- "node": ">= 6.0.0",
- "npm": ">= 3.0.0"
- }
- },
- "node_modules/socks": {
- "version": "2.7.1",
- "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz",
- "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==",
- "dependencies": {
- "ip": "^2.0.0",
- "smart-buffer": "^4.2.0"
- },
- "engines": {
- "node": ">= 10.13.0",
- "npm": ">= 3.0.0"
- }
- },
- "node_modules/sonic-boom": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.2.1.tgz",
- "integrity": "sha512-iITeTHxy3B9FGu8aVdiDXUVAcHMF9Ss0cCsAOo2HfCrmVGT3/DT5oYaeu0M/YKZDlKTvChEyPq0zI9Hf33EX6A==",
- "dependencies": {
- "atomic-sleep": "^1.0.0"
- }
- },
- "node_modules/sparse-bitfield": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
- "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
- "optional": true,
- "dependencies": {
- "memory-pager": "^1.0.2"
- }
- },
- "node_modules/split2": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz",
- "integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==",
- "engines": {
- "node": ">= 10.x"
- }
- },
- "node_modules/statuses": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
- "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/streamsearch": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
- "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
- "engines": {
- "node": ">=10.0.0"
- }
- },
- "node_modules/string_decoder": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
- "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
- "dependencies": {
- "safe-buffer": "~5.2.0"
- }
- },
- "node_modules/string-width": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
- "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
- "dependencies": {
- "eastasianwidth": "^0.2.0",
- "emoji-regex": "^9.2.2",
- "strip-ansi": "^7.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/strip-ansi": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.0.1.tgz",
- "integrity": "sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==",
- "dependencies": {
- "ansi-regex": "^6.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/strip-ansi?sponsor=1"
- }
- },
- "node_modules/strip-final-newline": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
- "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/strnum": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz",
- "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==",
- "optional": true
- },
- "node_modules/supports-color": {
- "version": "5.5.0",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
- "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
- "dev": true,
- "dependencies": {
- "has-flag": "^3.0.0"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/thread-stream": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.3.0.tgz",
- "integrity": "sha512-kaDqm1DET9pp3NXwR8382WHbnpXnRkN9xGN9dQt3B2+dmXiW8X1SOwmFOxAErEQ47ObhZ96J6yhZNXuyCOL7KA==",
- "dependencies": {
- "real-require": "^0.2.0"
- }
- },
- "node_modules/through": {
- "version": "2.3.8",
- "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
- "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
- },
- "node_modules/tiny-lru": {
- "version": "10.0.1",
- "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-10.0.1.tgz",
- "integrity": "sha512-Vst+6kEsWvb17Zpz14sRJV/f8bUWKhqm6Dc+v08iShmIJ/WxqWytHzCTd6m88pS33rE2zpX34TRmOpAJPloNCA==",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/tmp": {
- "version": "0.0.33",
- "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
- "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
- "dependencies": {
- "os-tmpdir": "~1.0.2"
- },
- "engines": {
- "node": ">=0.6.0"
- }
- },
- "node_modules/to-regex-range": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
- "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
- "dev": true,
- "dependencies": {
- "is-number": "^7.0.0"
- },
- "engines": {
- "node": ">=8.0"
- }
- },
- "node_modules/toidentifier": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
- "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
- "engines": {
- "node": ">=0.6"
- }
- },
- "node_modules/touch": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz",
- "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==",
- "dev": true,
- "dependencies": {
- "nopt": "~1.0.10"
- },
- "bin": {
- "nodetouch": "bin/nodetouch.js"
- }
- },
- "node_modules/tr46": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz",
- "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==",
- "dependencies": {
- "punycode": "^2.1.1"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/tslib": {
- "version": "1.14.1",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
- "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
- },
- "node_modules/type-fest": {
- "version": "2.19.0",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
- "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
- "engines": {
- "node": ">=12.20"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/type-is": {
- "version": "1.6.18",
- "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
- "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
- "dependencies": {
- "media-typer": "0.3.0",
- "mime-types": "~2.1.24"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/undefsafe": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
- "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
- "dev": true
- },
- "node_modules/undici": {
- "version": "5.20.0",
- "resolved": "https://registry.npmjs.org/undici/-/undici-5.20.0.tgz",
- "integrity": "sha512-J3j60dYzuo6Eevbawwp1sdg16k5Tf768bxYK4TUJRH7cBM4kFCbf3mOnM/0E3vQYXvpxITbbWmBafaDbxLDz3g==",
- "dependencies": {
- "busboy": "^1.6.0"
- },
- "engines": {
- "node": ">=12.18"
- }
- },
- "node_modules/universalify": {
- "version": "0.1.2",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
- "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
- "engines": {
- "node": ">= 4.0.0"
- }
- },
- "node_modules/unpipe": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
- "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/uri-js": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
- "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
- "dependencies": {
- "punycode": "^2.1.0"
- }
- },
- "node_modules/util": {
- "version": "0.10.4",
- "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz",
- "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==",
- "dev": true,
- "dependencies": {
- "inherits": "2.0.3"
- }
- },
- "node_modules/util-deprecate": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
- "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
- },
- "node_modules/util/node_modules/inherits": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
- "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
- "dev": true
- },
- "node_modules/utils-merge": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
- "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
- "engines": {
- "node": ">= 0.4.0"
- }
- },
- "node_modules/uuid": {
- "version": "8.3.2",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
- "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
- "optional": true,
- "bin": {
- "uuid": "dist/bin/uuid"
- }
- },
- "node_modules/vary": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
- "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/wcwidth": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
- "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==",
- "dependencies": {
- "defaults": "^1.0.3"
- }
- },
- "node_modules/webidl-conversions": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
- "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/whatwg-url": {
- "version": "11.0.0",
- "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz",
- "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==",
- "dependencies": {
- "tr46": "^3.0.0",
- "webidl-conversions": "^7.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/which": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
- "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "dependencies": {
- "isexe": "^2.0.0"
- },
- "bin": {
- "node-which": "bin/node-which"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/widest-line": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz",
- "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==",
- "dependencies": {
- "string-width": "^5.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/wrap-ansi": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
- "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
- "dependencies": {
- "ansi-styles": "^6.1.0",
- "string-width": "^5.0.1",
- "strip-ansi": "^7.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
- }
- },
- "node_modules/ws": {
- "version": "8.12.1",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.12.1.tgz",
- "integrity": "sha512-1qo+M9Ba+xNhPB+YTWUlK6M17brTut5EXbcBaMRN5pH5dFrXz7lzz1ChFSUq3bOUl8yEvSenhHmYUNJxFzdJew==",
- "engines": {
- "node": ">=10.0.0"
- },
- "peerDependencies": {
- "bufferutil": "^4.0.1",
- "utf-8-validate": ">=5.0.2"
- },
- "peerDependenciesMeta": {
- "bufferutil": {
- "optional": true
- },
- "utf-8-validate": {
- "optional": true
- }
- }
- },
- "node_modules/yallist": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
- }
- },
- "dependencies": {
- "@aws-crypto/ie11-detection": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-3.0.0.tgz",
- "integrity": "sha512-341lBBkiY1DfDNKai/wXM3aujNBkXR7tq1URPQDL9wi3AUbI80NR74uF1TXHMm7po1AcnFk8iu2S2IeU/+/A+Q==",
- "optional": true,
- "requires": {
- "tslib": "^1.11.1"
- }
- },
- "@aws-crypto/sha256-browser": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-3.0.0.tgz",
- "integrity": "sha512-8VLmW2B+gjFbU5uMeqtQM6Nj0/F1bro80xQXCW6CQBWgosFWXTx77aeOF5CAIAmbOK64SdMBJdNr6J41yP5mvQ==",
- "optional": true,
- "requires": {
- "@aws-crypto/ie11-detection": "^3.0.0",
- "@aws-crypto/sha256-js": "^3.0.0",
- "@aws-crypto/supports-web-crypto": "^3.0.0",
- "@aws-crypto/util": "^3.0.0",
- "@aws-sdk/types": "^3.222.0",
- "@aws-sdk/util-locate-window": "^3.0.0",
- "@aws-sdk/util-utf8-browser": "^3.0.0",
- "tslib": "^1.11.1"
- }
- },
- "@aws-crypto/sha256-js": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-3.0.0.tgz",
- "integrity": "sha512-PnNN7os0+yd1XvXAy23CFOmTbMaDxgxXtTKHybrJ39Y8kGzBATgBFibWJKH6BhytLI/Zyszs87xCOBNyBig6vQ==",
- "optional": true,
- "requires": {
- "@aws-crypto/util": "^3.0.0",
- "@aws-sdk/types": "^3.222.0",
- "tslib": "^1.11.1"
- }
- },
- "@aws-crypto/supports-web-crypto": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-3.0.0.tgz",
- "integrity": "sha512-06hBdMwUAb2WFTuGG73LSC0wfPu93xWwo5vL2et9eymgmu3Id5vFAHBbajVWiGhPO37qcsdCap/FqXvJGJWPIg==",
- "optional": true,
- "requires": {
- "tslib": "^1.11.1"
- }
- },
- "@aws-crypto/util": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-3.0.0.tgz",
- "integrity": "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==",
- "optional": true,
- "requires": {
- "@aws-sdk/types": "^3.222.0",
- "@aws-sdk/util-utf8-browser": "^3.0.0",
- "tslib": "^1.11.1"
- }
- },
- "@aws-sdk/abort-controller": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/abort-controller/-/abort-controller-3.272.0.tgz",
- "integrity": "sha512-s2TV3phapcTwZNr4qLxbfuQuE9ZMP4RoJdkvRRCkKdm6jslsWLJf2Zlcxti/23hOlINUMYv2iXE2pftIgWGdpg==",
- "optional": true,
- "requires": {
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/client-cognito-identity": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.282.0.tgz",
- "integrity": "sha512-OU9Wy50u31Mog4xmj9o+lLOb/y+yuQBTFwEVYApJtCkPsI2e3DtZFt36IcAy04fcjNUaSD3u6SGgfYo2vDQ2zA==",
- "optional": true,
- "requires": {
- "@aws-crypto/sha256-browser": "3.0.0",
- "@aws-crypto/sha256-js": "3.0.0",
- "@aws-sdk/client-sts": "3.282.0",
- "@aws-sdk/config-resolver": "3.282.0",
- "@aws-sdk/credential-provider-node": "3.282.0",
- "@aws-sdk/fetch-http-handler": "3.282.0",
- "@aws-sdk/hash-node": "3.272.0",
- "@aws-sdk/invalid-dependency": "3.272.0",
- "@aws-sdk/middleware-content-length": "3.282.0",
- "@aws-sdk/middleware-endpoint": "3.282.0",
- "@aws-sdk/middleware-host-header": "3.282.0",
- "@aws-sdk/middleware-logger": "3.272.0",
- "@aws-sdk/middleware-recursion-detection": "3.282.0",
- "@aws-sdk/middleware-retry": "3.282.0",
- "@aws-sdk/middleware-serde": "3.272.0",
- "@aws-sdk/middleware-signing": "3.282.0",
- "@aws-sdk/middleware-stack": "3.272.0",
- "@aws-sdk/middleware-user-agent": "3.282.0",
- "@aws-sdk/node-config-provider": "3.272.0",
- "@aws-sdk/node-http-handler": "3.282.0",
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/smithy-client": "3.279.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/url-parser": "3.272.0",
- "@aws-sdk/util-base64": "3.208.0",
- "@aws-sdk/util-body-length-browser": "3.188.0",
- "@aws-sdk/util-body-length-node": "3.208.0",
- "@aws-sdk/util-defaults-mode-browser": "3.279.0",
- "@aws-sdk/util-defaults-mode-node": "3.282.0",
- "@aws-sdk/util-endpoints": "3.272.0",
- "@aws-sdk/util-retry": "3.272.0",
- "@aws-sdk/util-user-agent-browser": "3.282.0",
- "@aws-sdk/util-user-agent-node": "3.282.0",
- "@aws-sdk/util-utf8": "3.254.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/client-sso": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.282.0.tgz",
- "integrity": "sha512-VzdCCaxlDyU+7wvLDWh+uACQ6RPfaKLQ3yJ2UY0B0SkH4R0E4GLDJ2OJzqS5eyyOsnq1rxfY75S4WYzj8E2cvg==",
- "optional": true,
- "requires": {
- "@aws-crypto/sha256-browser": "3.0.0",
- "@aws-crypto/sha256-js": "3.0.0",
- "@aws-sdk/config-resolver": "3.282.0",
- "@aws-sdk/fetch-http-handler": "3.282.0",
- "@aws-sdk/hash-node": "3.272.0",
- "@aws-sdk/invalid-dependency": "3.272.0",
- "@aws-sdk/middleware-content-length": "3.282.0",
- "@aws-sdk/middleware-endpoint": "3.282.0",
- "@aws-sdk/middleware-host-header": "3.282.0",
- "@aws-sdk/middleware-logger": "3.272.0",
- "@aws-sdk/middleware-recursion-detection": "3.282.0",
- "@aws-sdk/middleware-retry": "3.282.0",
- "@aws-sdk/middleware-serde": "3.272.0",
- "@aws-sdk/middleware-stack": "3.272.0",
- "@aws-sdk/middleware-user-agent": "3.282.0",
- "@aws-sdk/node-config-provider": "3.272.0",
- "@aws-sdk/node-http-handler": "3.282.0",
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/smithy-client": "3.279.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/url-parser": "3.272.0",
- "@aws-sdk/util-base64": "3.208.0",
- "@aws-sdk/util-body-length-browser": "3.188.0",
- "@aws-sdk/util-body-length-node": "3.208.0",
- "@aws-sdk/util-defaults-mode-browser": "3.279.0",
- "@aws-sdk/util-defaults-mode-node": "3.282.0",
- "@aws-sdk/util-endpoints": "3.272.0",
- "@aws-sdk/util-retry": "3.272.0",
- "@aws-sdk/util-user-agent-browser": "3.282.0",
- "@aws-sdk/util-user-agent-node": "3.282.0",
- "@aws-sdk/util-utf8": "3.254.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/client-sso-oidc": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.282.0.tgz",
- "integrity": "sha512-upC4yBZllAXg5OVIuS8Lu9MI1aqfAObl2BBixj9fIYbDanQ02s0b1IwfZqlOqNNkGzMko1AWyiOSyOdVgyJ+xg==",
- "optional": true,
- "requires": {
- "@aws-crypto/sha256-browser": "3.0.0",
- "@aws-crypto/sha256-js": "3.0.0",
- "@aws-sdk/config-resolver": "3.282.0",
- "@aws-sdk/fetch-http-handler": "3.282.0",
- "@aws-sdk/hash-node": "3.272.0",
- "@aws-sdk/invalid-dependency": "3.272.0",
- "@aws-sdk/middleware-content-length": "3.282.0",
- "@aws-sdk/middleware-endpoint": "3.282.0",
- "@aws-sdk/middleware-host-header": "3.282.0",
- "@aws-sdk/middleware-logger": "3.272.0",
- "@aws-sdk/middleware-recursion-detection": "3.282.0",
- "@aws-sdk/middleware-retry": "3.282.0",
- "@aws-sdk/middleware-serde": "3.272.0",
- "@aws-sdk/middleware-stack": "3.272.0",
- "@aws-sdk/middleware-user-agent": "3.282.0",
- "@aws-sdk/node-config-provider": "3.272.0",
- "@aws-sdk/node-http-handler": "3.282.0",
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/smithy-client": "3.279.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/url-parser": "3.272.0",
- "@aws-sdk/util-base64": "3.208.0",
- "@aws-sdk/util-body-length-browser": "3.188.0",
- "@aws-sdk/util-body-length-node": "3.208.0",
- "@aws-sdk/util-defaults-mode-browser": "3.279.0",
- "@aws-sdk/util-defaults-mode-node": "3.282.0",
- "@aws-sdk/util-endpoints": "3.272.0",
- "@aws-sdk/util-retry": "3.272.0",
- "@aws-sdk/util-user-agent-browser": "3.282.0",
- "@aws-sdk/util-user-agent-node": "3.282.0",
- "@aws-sdk/util-utf8": "3.254.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/client-sts": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.282.0.tgz",
- "integrity": "sha512-JZybEaST0rloS9drlX/0yJAnKHuV7DlS1n1WZxgaM2DY704ydlGiviiPQvC/q/dItsX4017gscC0blGJcUjK1g==",
- "optional": true,
- "requires": {
- "@aws-crypto/sha256-browser": "3.0.0",
- "@aws-crypto/sha256-js": "3.0.0",
- "@aws-sdk/config-resolver": "3.282.0",
- "@aws-sdk/credential-provider-node": "3.282.0",
- "@aws-sdk/fetch-http-handler": "3.282.0",
- "@aws-sdk/hash-node": "3.272.0",
- "@aws-sdk/invalid-dependency": "3.272.0",
- "@aws-sdk/middleware-content-length": "3.282.0",
- "@aws-sdk/middleware-endpoint": "3.282.0",
- "@aws-sdk/middleware-host-header": "3.282.0",
- "@aws-sdk/middleware-logger": "3.272.0",
- "@aws-sdk/middleware-recursion-detection": "3.282.0",
- "@aws-sdk/middleware-retry": "3.282.0",
- "@aws-sdk/middleware-sdk-sts": "3.282.0",
- "@aws-sdk/middleware-serde": "3.272.0",
- "@aws-sdk/middleware-signing": "3.282.0",
- "@aws-sdk/middleware-stack": "3.272.0",
- "@aws-sdk/middleware-user-agent": "3.282.0",
- "@aws-sdk/node-config-provider": "3.272.0",
- "@aws-sdk/node-http-handler": "3.282.0",
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/smithy-client": "3.279.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/url-parser": "3.272.0",
- "@aws-sdk/util-base64": "3.208.0",
- "@aws-sdk/util-body-length-browser": "3.188.0",
- "@aws-sdk/util-body-length-node": "3.208.0",
- "@aws-sdk/util-defaults-mode-browser": "3.279.0",
- "@aws-sdk/util-defaults-mode-node": "3.282.0",
- "@aws-sdk/util-endpoints": "3.272.0",
- "@aws-sdk/util-retry": "3.272.0",
- "@aws-sdk/util-user-agent-browser": "3.282.0",
- "@aws-sdk/util-user-agent-node": "3.282.0",
- "@aws-sdk/util-utf8": "3.254.0",
- "fast-xml-parser": "4.1.2",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/config-resolver": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/config-resolver/-/config-resolver-3.282.0.tgz",
- "integrity": "sha512-30qFLh2N4NXQ2EAook7NIFeu1K/nlrRLrdVb2BtGFi/F3cZnz+sy9o0XmL6x+sO9TznWjdNxD1RKQdqoAwGnCQ==",
- "optional": true,
- "requires": {
- "@aws-sdk/signature-v4": "3.282.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/util-config-provider": "3.208.0",
- "@aws-sdk/util-middleware": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/credential-provider-cognito-identity": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.282.0.tgz",
- "integrity": "sha512-GsLOt6GzckLQbMzgXOblKcRtXyMu3NcP0vFkYpy4r9oEzoxqPhy1yUpRNLeDv7r2qoa8naN81F5FwPwd17PrKg==",
- "optional": true,
- "requires": {
- "@aws-sdk/client-cognito-identity": "3.282.0",
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/credential-provider-env": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.272.0.tgz",
- "integrity": "sha512-QI65NbLnKLYHyTYhXaaUrq6eVsCCrMUb05WDA7+TJkWkjXesovpjc8vUKgFiLSxmgKmb2uOhHNcDyObKMrYQFw==",
- "optional": true,
- "requires": {
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/credential-provider-imds": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-imds/-/credential-provider-imds-3.272.0.tgz",
- "integrity": "sha512-wwAfVY1jTFQEfxVfdYD5r5ieYGl+0g4nhekVxNMqE8E1JeRDd18OqiwAflzpgBIqxfqvCUkf+vl5JYyacMkNAQ==",
- "optional": true,
- "requires": {
- "@aws-sdk/node-config-provider": "3.272.0",
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/url-parser": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/credential-provider-ini": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.282.0.tgz",
- "integrity": "sha512-2GKduXORcUgOigF1jZF7A1Wh4W/aJt3ynh7xb1vfx020nHx6YDljrEGpzgH6pOVzl7ZhgthpojicCuy2UumkMA==",
- "optional": true,
- "requires": {
- "@aws-sdk/credential-provider-env": "3.272.0",
- "@aws-sdk/credential-provider-imds": "3.272.0",
- "@aws-sdk/credential-provider-process": "3.272.0",
- "@aws-sdk/credential-provider-sso": "3.282.0",
- "@aws-sdk/credential-provider-web-identity": "3.272.0",
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/shared-ini-file-loader": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/credential-provider-node": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.282.0.tgz",
- "integrity": "sha512-qyHipZW0ep8STY+SO+Me8ObQ1Ee/aaZTmAK0Os/gB+EsiZhIE+mi6zRcScwdnpgJPLRYMEe4p/Cr6DOrA0G0GQ==",
- "optional": true,
- "requires": {
- "@aws-sdk/credential-provider-env": "3.272.0",
- "@aws-sdk/credential-provider-imds": "3.272.0",
- "@aws-sdk/credential-provider-ini": "3.282.0",
- "@aws-sdk/credential-provider-process": "3.272.0",
- "@aws-sdk/credential-provider-sso": "3.282.0",
- "@aws-sdk/credential-provider-web-identity": "3.272.0",
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/shared-ini-file-loader": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/credential-provider-process": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.272.0.tgz",
- "integrity": "sha512-hiCAjWWm2PeBFp5cjkxqyam/XADjiS+e7GzwC34TbZn3LisS0uoweLojj9tD11NnnUhyhbLteUvu5+rotOLwrg==",
- "optional": true,
- "requires": {
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/shared-ini-file-loader": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/credential-provider-sso": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.282.0.tgz",
- "integrity": "sha512-c4nibry7u0hkYRMi7+cWzdwYXfDDG+j3VYFxk2oOvU1VIJRyE6oeJqVaz3jgYLX9brHyrLJjuFCIJCUV/WXgIA==",
- "optional": true,
- "requires": {
- "@aws-sdk/client-sso": "3.282.0",
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/shared-ini-file-loader": "3.272.0",
- "@aws-sdk/token-providers": "3.282.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/credential-provider-web-identity": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.272.0.tgz",
- "integrity": "sha512-ImrHMkcgneGa/HadHAQXPwOrX26sAKuB8qlMxZF/ZCM2B55u8deY+ZVkVuraeKb7YsahMGehPFOfRAF6mvFI5Q==",
- "optional": true,
- "requires": {
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/credential-providers": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.282.0.tgz",
- "integrity": "sha512-/Pau2Ht15j26ibTSTaJHbx6wA3suNT0Qgu+++6ZUoVCeHL5ZN/otcoebsR/lOZTw8Fji7K5kl8TW41UNAE8s2w==",
- "optional": true,
- "requires": {
- "@aws-sdk/client-cognito-identity": "3.282.0",
- "@aws-sdk/client-sso": "3.282.0",
- "@aws-sdk/client-sts": "3.282.0",
- "@aws-sdk/credential-provider-cognito-identity": "3.282.0",
- "@aws-sdk/credential-provider-env": "3.272.0",
- "@aws-sdk/credential-provider-imds": "3.272.0",
- "@aws-sdk/credential-provider-ini": "3.282.0",
- "@aws-sdk/credential-provider-node": "3.282.0",
- "@aws-sdk/credential-provider-process": "3.272.0",
- "@aws-sdk/credential-provider-sso": "3.282.0",
- "@aws-sdk/credential-provider-web-identity": "3.272.0",
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/shared-ini-file-loader": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/fetch-http-handler": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/fetch-http-handler/-/fetch-http-handler-3.282.0.tgz",
- "integrity": "sha512-RTd53UzKtUucIEdVLGGgtlbVwp0QkOt3ZfHuA/A1lOH7meChSh1kz7B5z3p4HQDpXO+MQ1Y6Ble9Vg2fh1zwJQ==",
- "optional": true,
- "requires": {
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/querystring-builder": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/util-base64": "3.208.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/hash-node": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/hash-node/-/hash-node-3.272.0.tgz",
- "integrity": "sha512-40dwND+iAm3VtPHPZu7/+CIdVJFk2s0cWZt1lOiMPMSXycSYJ45wMk7Lly3uoqRx0uWfFK5iT2OCv+fJi5jTng==",
- "optional": true,
- "requires": {
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/util-buffer-from": "3.208.0",
- "@aws-sdk/util-utf8": "3.254.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/invalid-dependency": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/invalid-dependency/-/invalid-dependency-3.272.0.tgz",
- "integrity": "sha512-ysW6wbjl1Y78txHUQ/Tldj2Rg1BI7rpMO9B9xAF6yAX3mQ7t6SUPQG/ewOGvH2208NBIl3qP5e/hDf0Q6r/1iw==",
- "optional": true,
- "requires": {
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/is-array-buffer": {
- "version": "3.201.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/is-array-buffer/-/is-array-buffer-3.201.0.tgz",
- "integrity": "sha512-UPez5qLh3dNgt0DYnPD/q0mVJY84rA17QE26hVNOW3fAji8W2wrwrxdacWOxyXvlxWsVRcKmr+lay1MDqpAMfg==",
- "optional": true,
- "requires": {
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/middleware-content-length": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-content-length/-/middleware-content-length-3.282.0.tgz",
- "integrity": "sha512-SDgMLRRTMr9LlHSNk4bXUXynYnkT4oNMqE+FxhjsdbT8hK36eS4AadM58R7nPwgjR3EuWRW4ZRRawLWatpWspA==",
- "optional": true,
- "requires": {
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/middleware-endpoint": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint/-/middleware-endpoint-3.282.0.tgz",
- "integrity": "sha512-8U9Mv/Sbdo1KI6/ip7IIUdBl5pgmalFbfkYAyO+AtmkEvawI9ipdWFs5HB0Dwd1BGVup5choY72Ik/7sCAAFTQ==",
- "optional": true,
- "requires": {
- "@aws-sdk/middleware-serde": "3.272.0",
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/signature-v4": "3.282.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/url-parser": "3.272.0",
- "@aws-sdk/util-config-provider": "3.208.0",
- "@aws-sdk/util-middleware": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/middleware-host-header": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.282.0.tgz",
- "integrity": "sha512-90dfYow4zh4tCatTOnqB3nE/dIAucQLZnMqwN/WBPu0fUqjymzpsNkPchqWBPnSWdNE8w3PiKMqqD9rjYwqw4Q==",
- "optional": true,
- "requires": {
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/middleware-logger": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.272.0.tgz",
- "integrity": "sha512-u2SQ0hWrFwxbxxYMG5uMEgf01pQY5jauK/LYWgGIvuCmFgiyRQQP3oN7kkmsxnS9MWmNmhbyQguX2NY02s5e9w==",
- "optional": true,
- "requires": {
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/middleware-recursion-detection": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.282.0.tgz",
- "integrity": "sha512-cSLq/daEaTEucbP/TgAXIOcpwLu7Bfw3VGzH1U56ngDjI4KWvUheF16JiB6OqKQXduPBPsdZ9dVmkDVKddmCRw==",
- "optional": true,
- "requires": {
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/middleware-retry": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-retry/-/middleware-retry-3.282.0.tgz",
- "integrity": "sha512-3+0M1GP9o480IdqHVZbkhTgge63uKhDFlS6cQznpNGj0eIuQPhXRnlEz2/rma0INUqFm6+7qJ5yzHR4WQbfHpw==",
- "optional": true,
- "requires": {
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/service-error-classification": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/util-middleware": "3.272.0",
- "@aws-sdk/util-retry": "3.272.0",
- "tslib": "^2.3.1",
- "uuid": "^8.3.2"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/middleware-sdk-sts": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sts/-/middleware-sdk-sts-3.282.0.tgz",
- "integrity": "sha512-Qe20mtJcF6lxt7280FhTFD2IpBDn39MEXmbm/zIkXR2/cAmvji8YhcxhNrq1l7XiuMM6SokBDC/f3dlF1oOC6g==",
- "optional": true,
- "requires": {
- "@aws-sdk/middleware-signing": "3.282.0",
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/signature-v4": "3.282.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/middleware-serde": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-serde/-/middleware-serde-3.272.0.tgz",
- "integrity": "sha512-kW1uOxgPSwtXPB5rm3QLdWomu42lkYpQL94tM1BjyFOWmBLO2lQhk5a7Dw6HkTozT9a+vxtscLChRa6KZe61Hw==",
- "optional": true,
- "requires": {
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/middleware-signing": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.282.0.tgz",
- "integrity": "sha512-eE5qMDcqqxZPdSwybUEph/knrA2j2cHjW+B2ddROw3Ojg0XLjep5hOhithAudgBREQhYF9pdsBr6mUMynUIrKw==",
- "optional": true,
- "requires": {
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/signature-v4": "3.282.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/util-middleware": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/middleware-stack": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-stack/-/middleware-stack-3.272.0.tgz",
- "integrity": "sha512-jhwhknnPBGhfXAGV5GXUWfEhDFoP/DN8MPCO2yC5OAxyp6oVJ8lTPLkZYMTW5VL0c0eG44dXpF4Ib01V+PlDrQ==",
- "optional": true,
- "requires": {
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/middleware-user-agent": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.282.0.tgz",
- "integrity": "sha512-P1ealsSrUALo0w0Qu5nBKsNQwsmqIfsoNtFWpaznjIcXE5rRMlZL69zb0KnGbQCBfEXsgaMOWjeGT8I3/XbOHQ==",
- "optional": true,
- "requires": {
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/node-config-provider": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/node-config-provider/-/node-config-provider-3.272.0.tgz",
- "integrity": "sha512-YYCIBh9g1EQo7hm2l22HX5Yr9RoPQ2RCvhzKvF1n1e8t1QH4iObQrYUtqHG4khcm64Cft8C5MwZmgzHbya5Z6Q==",
- "optional": true,
- "requires": {
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/shared-ini-file-loader": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/node-http-handler": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/node-http-handler/-/node-http-handler-3.282.0.tgz",
- "integrity": "sha512-LIA4lsSKA/l1kTR5ERkJG2gARveB7Y40MR6yDwtIuhXeVu7Xo9m4BJFanCYIbyc093W0T53x438bwoBR+R+/fw==",
- "optional": true,
- "requires": {
- "@aws-sdk/abort-controller": "3.272.0",
- "@aws-sdk/protocol-http": "3.282.0",
- "@aws-sdk/querystring-builder": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/property-provider": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/property-provider/-/property-provider-3.272.0.tgz",
- "integrity": "sha512-V1pZTaH5eqpAt8O8CzbItHhOtzIfFuWymvwZFkAtwKuaHpnl7jjrTouV482zoq8AD/fF+VVSshwBKYA7bhidIw==",
- "optional": true,
- "requires": {
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/protocol-http": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/protocol-http/-/protocol-http-3.282.0.tgz",
- "integrity": "sha512-aOPv5DhsbG06WKfeh2g0H8RGnaeI8pLhaA+Mq1BvzXcghhlDu+FM9K/GjC/f1lWk1UNryfevOR7SdQm95ciHQg==",
- "optional": true,
- "requires": {
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/querystring-builder": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-builder/-/querystring-builder-3.272.0.tgz",
- "integrity": "sha512-ndo++7GkdCj5tBXE6rGcITpSpZS4PfyV38wntGYAlj9liL1omk3bLZRY6uzqqkJpVHqbg2fD7O2qHNItzZgqhw==",
- "optional": true,
- "requires": {
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/util-uri-escape": "3.201.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/querystring-parser": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-parser/-/querystring-parser-3.272.0.tgz",
- "integrity": "sha512-5oS4/9n6N1LZW9tI3qq/0GnCuWoOXRgcHVB+AJLRBvDbEe+GI+C/xK1tKLsfpDNgsQJHc4IPQoIt4megyZ/1+A==",
- "optional": true,
- "requires": {
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/service-error-classification": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/service-error-classification/-/service-error-classification-3.272.0.tgz",
- "integrity": "sha512-REoltM1LK9byyIufLqx9znhSolPcHQgVHIA2S0zu5sdt5qER4OubkLAXuo4MBbisUTmh8VOOvIyUb5ijZCXq1w==",
- "optional": true
- },
- "@aws-sdk/shared-ini-file-loader": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/shared-ini-file-loader/-/shared-ini-file-loader-3.272.0.tgz",
- "integrity": "sha512-lzFPohp5sy2XvwFjZIzLVCRpC0i5cwBiaXmFzXYQZJm6FSCszHO4ax+m9yrtlyVFF/2YPWl+/bzNthy4aJtseA==",
- "optional": true,
- "requires": {
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/signature-v4": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4/-/signature-v4-3.282.0.tgz",
- "integrity": "sha512-rnSL3UyF/No7+O2EMtN1sTCiqL1a+odbfnfo3wCSl8DH5PEYINt2kZgVEvT1Fgaffk1pUggBBOZoR+arPIIDJA==",
- "optional": true,
- "requires": {
- "@aws-sdk/is-array-buffer": "3.201.0",
- "@aws-sdk/types": "3.272.0",
- "@aws-sdk/util-hex-encoding": "3.201.0",
- "@aws-sdk/util-middleware": "3.272.0",
- "@aws-sdk/util-uri-escape": "3.201.0",
- "@aws-sdk/util-utf8": "3.254.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/smithy-client": {
- "version": "3.279.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/smithy-client/-/smithy-client-3.279.0.tgz",
- "integrity": "sha512-ZcYWUQDGAYN6NXRpJuSn46PetrpPCA6TrDVwP9+3pERzTXZ66npXoG2XhHjNrOXy/Ted5A3OxKrM4/zLu9tK3A==",
- "optional": true,
- "requires": {
- "@aws-sdk/middleware-stack": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/token-providers": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.282.0.tgz",
- "integrity": "sha512-Qk/D6i+Hpc0fp/2SRHbfJeKPgUIugzsmye3NL0OV1bqd1Y40dW5LT4u67VcZHwqxzYDKe6Eo+7NHJu7qfvwhog==",
- "optional": true,
- "requires": {
- "@aws-sdk/client-sso-oidc": "3.282.0",
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/shared-ini-file-loader": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/types": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.272.0.tgz",
- "integrity": "sha512-MmmL6vxMGP5Bsi+4wRx4mxYlU/LX6M0noOXrDh/x5FfG7/4ZOar/nDxqDadhJtNM88cuWVHZWY59P54JzkGWmA==",
- "optional": true,
- "requires": {
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/url-parser": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/url-parser/-/url-parser-3.272.0.tgz",
- "integrity": "sha512-vX/Tx02PlnQ/Kgtf5TnrNDHPNbY+amLZjW0Z1d9vzAvSZhQ4i9Y18yxoRDIaDTCNVRDjdhV8iuctW+05PB5JtQ==",
- "optional": true,
- "requires": {
- "@aws-sdk/querystring-parser": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/util-base64": {
- "version": "3.208.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-base64/-/util-base64-3.208.0.tgz",
- "integrity": "sha512-PQniZph5A6N7uuEOQi+1hnMz/FSOK/8kMFyFO+4DgA1dZ5pcKcn5wiFwHkcTb/BsgVqQa3Jx0VHNnvhlS8JyTg==",
- "optional": true,
- "requires": {
- "@aws-sdk/util-buffer-from": "3.208.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/util-body-length-browser": {
- "version": "3.188.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-body-length-browser/-/util-body-length-browser-3.188.0.tgz",
- "integrity": "sha512-8VpnwFWXhnZ/iRSl9mTf+VKOX9wDE8QtN4bj9pBfxwf90H1X7E8T6NkiZD3k+HubYf2J94e7DbeHs7fuCPW5Qg==",
- "optional": true,
- "requires": {
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/util-body-length-node": {
- "version": "3.208.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-body-length-node/-/util-body-length-node-3.208.0.tgz",
- "integrity": "sha512-3zj50e5g7t/MQf53SsuuSf0hEELzMtD8RX8C76f12OSRo2Bca4FLLYHe0TZbxcfQHom8/hOaeZEyTyMogMglqg==",
- "optional": true,
- "requires": {
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/util-buffer-from": {
- "version": "3.208.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-buffer-from/-/util-buffer-from-3.208.0.tgz",
- "integrity": "sha512-7L0XUixNEFcLUGPeBF35enCvB9Xl+K6SQsmbrPk1P3mlV9mguWSDQqbOBwY1Ir0OVbD6H/ZOQU7hI/9RtRI0Zw==",
- "optional": true,
- "requires": {
- "@aws-sdk/is-array-buffer": "3.201.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/util-config-provider": {
- "version": "3.208.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-config-provider/-/util-config-provider-3.208.0.tgz",
- "integrity": "sha512-DSRqwrERUsT34ug+anlMBIFooBEGwM8GejC7q00Y/9IPrQy50KnG5PW2NiTjuLKNi7pdEOlwTSEocJE15eDZIg==",
- "optional": true,
- "requires": {
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/util-defaults-mode-browser": {
- "version": "3.279.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-defaults-mode-browser/-/util-defaults-mode-browser-3.279.0.tgz",
- "integrity": "sha512-RnchYRrpapTT5Hu23LOfk6e8RMVq0kUzho6xA6TJj1a4uGxkcRMvgzPipCq1P5uHu0mrkQBg9pGPEVNOUs38/Q==",
- "optional": true,
- "requires": {
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "bowser": "^2.11.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/util-defaults-mode-node": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-defaults-mode-node/-/util-defaults-mode-node-3.282.0.tgz",
- "integrity": "sha512-D1BlFoA7ZMeK2diDUWFx1xBFrSaJuBZMRBuWbnbT9AnRYNCsASZ8DRU1KkZ8LuFQIwmZz94P9q683emYnZBhiw==",
- "optional": true,
- "requires": {
- "@aws-sdk/config-resolver": "3.282.0",
- "@aws-sdk/credential-provider-imds": "3.272.0",
- "@aws-sdk/node-config-provider": "3.272.0",
- "@aws-sdk/property-provider": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/util-endpoints": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.272.0.tgz",
- "integrity": "sha512-c4MPUaJt2G6gGpoiwIOqDfUa98c1J63RpYvf/spQEKOtC/tF5Gfqlxuq8FnAl5lHnrqj1B9ZXLLxFhHtDR0IiQ==",
- "optional": true,
- "requires": {
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/util-hex-encoding": {
- "version": "3.201.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-hex-encoding/-/util-hex-encoding-3.201.0.tgz",
- "integrity": "sha512-7t1vR1pVxKx0motd3X9rI3m/xNp78p3sHtP5yo4NP4ARpxyJ0fokBomY8ScaH2D/B+U5o9ARxldJUdMqyBlJcA==",
- "optional": true,
- "requires": {
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/util-locate-window": {
- "version": "3.208.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.208.0.tgz",
- "integrity": "sha512-iua1A2+P7JJEDHVgvXrRJSvsnzG7stYSGQnBVphIUlemwl6nN5D+QrgbjECtrbxRz8asYFHSzhdhECqN+tFiBg==",
- "optional": true,
- "requires": {
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/util-middleware": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-middleware/-/util-middleware-3.272.0.tgz",
- "integrity": "sha512-Abw8m30arbwxqmeMMha5J11ESpHUNmCeSqSzE8/C4B8jZQtHY4kq7f+upzcNIQ11lsd+uzBEzNG3+dDRi0XOJQ==",
- "optional": true,
- "requires": {
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/util-retry": {
- "version": "3.272.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-retry/-/util-retry-3.272.0.tgz",
- "integrity": "sha512-Ngha5414LR4gRHURVKC9ZYXsEJhMkm+SJ+44wlzOhavglfdcKKPUsibz5cKY1jpUV7oKECwaxHWpBB8r6h+hOg==",
- "optional": true,
- "requires": {
- "@aws-sdk/service-error-classification": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/util-uri-escape": {
- "version": "3.201.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-uri-escape/-/util-uri-escape-3.201.0.tgz",
- "integrity": "sha512-TeTWbGx4LU2c5rx0obHeDFeO9HvwYwQtMh1yniBz00pQb6Qt6YVOETVQikRZ+XRQwEyCg/dA375UplIpiy54mA==",
- "optional": true,
- "requires": {
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/util-user-agent-browser": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.282.0.tgz",
- "integrity": "sha512-Z639oyTa5fZfyi4Xr64+eiAwBCxfpe9Op4Vhnr1z/RwonQM/qywydv6Ttpeq1q5uQ0nG4wTkOMpfh39g+VqIgw==",
- "optional": true,
- "requires": {
- "@aws-sdk/types": "3.272.0",
- "bowser": "^2.11.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/util-user-agent-node": {
- "version": "3.282.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.282.0.tgz",
- "integrity": "sha512-GSOdWNmzEd554wR9HBrgeYptKBOybveVwUkd6ws+YTdCOz4xD5Gga+I5JomKkcMEUVdBrJnYVUtq7ZsJy2f11w==",
- "optional": true,
- "requires": {
- "@aws-sdk/node-config-provider": "3.272.0",
- "@aws-sdk/types": "3.272.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/util-utf8": {
- "version": "3.254.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8/-/util-utf8-3.254.0.tgz",
- "integrity": "sha512-14Kso/eIt5/qfIBmhEL9L1IfyUqswjSTqO2mY7KOzUZ9SZbwn3rpxmtkhmATkRjD7XIlLKaxBkI7tU9Zjzj8Kw==",
- "optional": true,
- "requires": {
- "@aws-sdk/util-buffer-from": "3.208.0",
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@aws-sdk/util-utf8-browser": {
- "version": "3.259.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.259.0.tgz",
- "integrity": "sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==",
- "optional": true,
- "requires": {
- "tslib": "^2.3.1"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
- "optional": true
- }
- }
- },
- "@dqbd/tiktoken": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/@dqbd/tiktoken/-/tiktoken-0.4.0.tgz",
- "integrity": "sha512-iaHgmwKAOqowBFZKxelyszoeGLoNw62eOULcmyme1aA1Ymr3JgYl0V7jwpuUm7fksalycZajx3loFn9TRUaviw=="
- },
- "@fastify/ajv-compiler": {
- "version": "3.5.0",
- "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.5.0.tgz",
- "integrity": "sha512-ebbEtlI7dxXF5ziNdr05mOY8NnDiPB1XvAlLHctRt/Rc+C3LCOVW5imUVX+mhvUhnNzmPBHewUkOFgGlCxgdAA==",
- "requires": {
- "ajv": "^8.11.0",
- "ajv-formats": "^2.1.1",
- "fast-uri": "^2.0.0"
- }
- },
- "@fastify/cors": {
- "version": "8.2.0",
- "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.2.0.tgz",
- "integrity": "sha512-qDgwpmg6C4D0D3nh8MTMuRXWyEwPnDZDBODaJv90FP2o9ukbahJByW4FtrM5Bpod5KbTf1oIExBmpItbUTQmHg==",
- "requires": {
- "fastify-plugin": "^4.0.0",
- "mnemonist": "0.39.5"
- }
- },
- "@fastify/deepmerge": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-1.3.0.tgz",
- "integrity": "sha512-J8TOSBq3SoZbDhM9+R/u77hP93gz/rajSA+K2kGyijPpORPWUXHUpTaleoj+92As0S9uPRP7Oi8IqMf0u+ro6A=="
- },
- "@fastify/error": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.2.0.tgz",
- "integrity": "sha512-KAfcLa+CnknwVi5fWogrLXgidLic+GXnLjijXdpl8pvkvbXU5BGa37iZO9FGvsh9ZL4y+oFi5cbHBm5UOG+dmQ=="
- },
- "@fastify/fast-json-stringify-compiler": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.2.0.tgz",
- "integrity": "sha512-ypZynRvXA3dibfPykQN3RB5wBdEUgSGgny8Qc6k163wYPLD4mEGEDkACp+00YmqkGvIm8D/xYoHajwyEdWD/eg==",
- "requires": {
- "fast-json-stringify": "^5.0.0"
- }
- },
- "@keyv/mongo": {
- "version": "2.1.8",
- "resolved": "https://registry.npmjs.org/@keyv/mongo/-/mongo-2.1.8.tgz",
- "integrity": "sha512-IOFKS9Y10c42NCaoD/6OKmqz7FMCm/VbMbrip7ma8tBvdWcPhDkkPV3ZpLgGsGw39RePzzKO6FQ89xs0+BFCKg==",
- "requires": {
- "mongodb": "^4.5.0",
- "pify": "^5.0.0"
- }
- },
- "@types/node": {
- "version": "18.14.6",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.6.tgz",
- "integrity": "sha512-93+VvleD3mXwlLI/xASjw0FzKcwzl3OdTCzm1LaRfqgS21gfFtK3zDXM5Op9TeeMsJVOaJ2VRDpT9q4Y3d0AvA=="
- },
- "@types/webidl-conversions": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
- "integrity": "sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog=="
- },
- "@types/whatwg-url": {
- "version": "8.2.2",
- "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz",
- "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==",
- "requires": {
- "@types/node": "*",
- "@types/webidl-conversions": "*"
- }
- },
- "@vscode/vscode-languagedetection": {
- "version": "1.0.22",
- "resolved": "https://registry.npmjs.org/@vscode/vscode-languagedetection/-/vscode-languagedetection-1.0.22.tgz",
- "integrity": "sha512-rQ/BgMyLuIXSmbA0MSkIPHtcOw14QkeDbAq19sjvaS9LTRr905yij0S8lsyqN5JgOsbtIx7pAcyOxFMzPmqhZQ=="
- },
- "@waylaidwanderer/chatgpt-api": {
- "version": "1.28.2",
- "resolved": "https://registry.npmjs.org/@waylaidwanderer/chatgpt-api/-/chatgpt-api-1.28.2.tgz",
- "integrity": "sha512-efNvZr8uosiYD69zFq50OPM36s+tyRMixlHpwDzn2q9UuZrdHC++kmm23OAnDxv3/+vA4UwCsZXn+92c35NHBQ==",
- "requires": {
- "@dqbd/tiktoken": "^0.4.0",
- "@fastify/cors": "^8.2.0",
- "@waylaidwanderer/fastify-sse-v2": "^3.1.0",
- "@waylaidwanderer/fetch-event-source": "^3.0.1",
- "boxen": "^7.0.1",
- "clipboardy": "^3.0.0",
- "dotenv": "^16.0.3",
- "fastify": "^4.11.0",
- "fetch-undici": "^3.0.1",
- "https-proxy-agent": "^5.0.1",
- "inquirer": "^9.1.4",
- "inquirer-autocomplete-prompt": "^3.0.0",
- "keyv": "^4.5.2",
- "keyv-file": "^0.2.0",
- "ora": "^6.1.2",
- "undici": "^5.20.0",
- "ws": "^8.12.0"
- }
- },
- "@waylaidwanderer/fastify-sse-v2": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/@waylaidwanderer/fastify-sse-v2/-/fastify-sse-v2-3.1.0.tgz",
- "integrity": "sha512-R6/VT14+iGZmyp7Jih7FYZuWr0B0gJ9uym1xoVPlKjZBngzFS2bL8yvZyEIPbMrTjrC8syZY2z2WuMHsipRfpw==",
- "requires": {
- "fastify-plugin": "^4.3.0",
- "it-pushable": "^1.4.2",
- "it-to-stream": "^1.0.0"
- }
- },
- "@waylaidwanderer/fetch-event-source": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/@waylaidwanderer/fetch-event-source/-/fetch-event-source-3.0.1.tgz",
- "integrity": "sha512-gkc7vmBW9uulRj7tY30/1D8iBrpcgphBpI+e7LP744x/hAzaQxUuyF+n4O5dctKx+dE3i4BFuCWMEz9fAx2jlQ=="
- },
- "abbrev": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
- "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
- "dev": true
- },
- "abort-controller": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
- "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
- "requires": {
- "event-target-shim": "^5.0.0"
- }
- },
- "abstract-logging": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
- "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="
- },
- "accepts": {
- "version": "1.3.8",
- "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
- "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
- "requires": {
- "mime-types": "~2.1.34",
- "negotiator": "0.6.3"
- }
- },
- "agent-base": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
- "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
- "requires": {
- "debug": "4"
- }
- },
- "ajv": {
- "version": "8.12.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
- "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
- "requires": {
- "fast-deep-equal": "^3.1.1",
- "json-schema-traverse": "^1.0.0",
- "require-from-string": "^2.0.2",
- "uri-js": "^4.2.2"
- }
- },
- "ajv-formats": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
- "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
- "requires": {
- "ajv": "^8.0.0"
- }
- },
- "ansi-align": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz",
- "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==",
- "requires": {
- "string-width": "^4.1.0"
- },
- "dependencies": {
- "ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
- },
- "emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
- },
- "string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "requires": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- }
- },
- "strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "requires": {
- "ansi-regex": "^5.0.1"
- }
- }
- }
- },
- "ansi-escapes": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.0.0.tgz",
- "integrity": "sha512-IG23inYII3dWlU2EyiAiGj6Bwal5GzsgPMwjYGvc1HPE2dgbj4ZB5ToWBKSquKw74nB3TIuOwaI6/jSULzfgrw==",
- "requires": {
- "type-fest": "^3.0.0"
- },
- "dependencies": {
- "type-fest": {
- "version": "3.6.1",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.6.1.tgz",
- "integrity": "sha512-htXWckxlT6U4+ilVgweNliPqlsVSSucbxVexRYllyMVJDtf5rTjv6kF/s+qAd4QSL1BZcnJPEJavYBPQiWuZDA=="
- }
- }
- },
- "ansi-regex": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
- "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA=="
- },
- "ansi-styles": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
- "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="
- },
- "anymatch": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
- "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
- "dev": true,
- "requires": {
- "normalize-path": "^3.0.0",
- "picomatch": "^2.0.4"
- }
- },
- "arch": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz",
- "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ=="
- },
- "archy": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz",
- "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw=="
- },
- "array-flatten": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
- "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
- },
- "asynckit": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
- "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
- },
- "atomic-sleep": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
- "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="
- },
- "avvio": {
- "version": "8.2.1",
- "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.2.1.tgz",
- "integrity": "sha512-TAlMYvOuwGyLK3PfBb5WKBXZmXz2fVCgv23d6zZFdle/q3gPjmxBaeuC0pY0Dzs5PWMSgfqqEZkrye19GlDTgw==",
- "requires": {
- "archy": "^1.0.0",
- "debug": "^4.0.0",
- "fastq": "^1.6.1"
- }
- },
- "axios": {
- "version": "0.26.1",
- "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz",
- "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==",
- "requires": {
- "follow-redirects": "^1.14.8"
- }
- },
- "balanced-match": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "dev": true
- },
- "base64-js": {
- "version": "1.5.1",
- "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
- "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
- },
- "binary-extensions": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
- "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
- "dev": true
- },
- "bl": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz",
- "integrity": "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==",
- "requires": {
- "buffer": "^6.0.3",
- "inherits": "^2.0.4",
- "readable-stream": "^3.4.0"
- }
- },
- "body-parser": {
- "version": "1.20.1",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz",
- "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==",
- "requires": {
- "bytes": "3.1.2",
- "content-type": "~1.0.4",
- "debug": "2.6.9",
- "depd": "2.0.0",
- "destroy": "1.2.0",
- "http-errors": "2.0.0",
- "iconv-lite": "0.4.24",
- "on-finished": "2.4.1",
- "qs": "6.11.0",
- "raw-body": "2.5.1",
- "type-is": "~1.6.18",
- "unpipe": "1.0.0"
- },
- "dependencies": {
- "debug": {
- "version": "2.6.9",
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "requires": {
- "ms": "2.0.0"
- }
- },
- "ms": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
- }
- }
- },
- "bowser": {
- "version": "2.11.0",
- "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz",
- "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==",
- "optional": true
- },
- "boxen": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.2.tgz",
- "integrity": "sha512-1Z4UJabXUP1/R9rLpoU3O2lEMnG3pPLAs/ZD2lF3t2q7qD5lM8rqbtnvtvm4N0wEyNlE+9yZVTVAGmd1V5jabg==",
- "requires": {
- "ansi-align": "^3.0.1",
- "camelcase": "^7.0.0",
- "chalk": "^5.0.1",
- "cli-boxes": "^3.0.0",
- "string-width": "^5.1.2",
- "type-fest": "^2.13.0",
- "widest-line": "^4.0.1",
- "wrap-ansi": "^8.0.1"
- }
- },
- "brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
- "dev": true,
- "requires": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
- "braces": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
- "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
- "dev": true,
- "requires": {
- "fill-range": "^7.0.1"
- }
- },
- "bson": {
- "version": "4.7.2",
- "resolved": "https://registry.npmjs.org/bson/-/bson-4.7.2.tgz",
- "integrity": "sha512-Ry9wCtIZ5kGqkJoi6aD8KjxFZEx78guTQDnpXWiNthsxzrxAK/i8E6pCHAIZTbaEFWcOCvbecMukfK7XUvyLpQ==",
- "requires": {
- "buffer": "^5.6.0"
- },
- "dependencies": {
- "buffer": {
- "version": "5.7.1",
- "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
- "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
- "requires": {
- "base64-js": "^1.3.1",
- "ieee754": "^1.1.13"
- }
- }
- }
- },
- "buffer": {
- "version": "6.0.3",
- "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
- "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
- "requires": {
- "base64-js": "^1.3.1",
- "ieee754": "^1.2.1"
- }
- },
- "busboy": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
- "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
- "requires": {
- "streamsearch": "^1.1.0"
- }
- },
- "bytes": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
- "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="
- },
- "call-bind": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
- "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
- "requires": {
- "function-bind": "^1.1.1",
- "get-intrinsic": "^1.0.2"
- }
- },
- "camelcase": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz",
- "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw=="
- },
- "chalk": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.2.0.tgz",
- "integrity": "sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA=="
- },
- "chardet": {
- "version": "0.7.0",
- "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
- "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="
- },
- "chokidar": {
- "version": "3.5.3",
- "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
- "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
- "dev": true,
- "requires": {
- "anymatch": "~3.1.2",
- "braces": "~3.0.2",
- "fsevents": "~2.3.2",
- "glob-parent": "~5.1.2",
- "is-binary-path": "~2.1.0",
- "is-glob": "~4.0.1",
- "normalize-path": "~3.0.0",
- "readdirp": "~3.6.0"
- }
- },
- "cli-boxes": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
- "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="
- },
- "cli-cursor": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz",
- "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==",
- "requires": {
- "restore-cursor": "^4.0.0"
- }
- },
- "cli-spinners": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.7.0.tgz",
- "integrity": "sha512-qu3pN8Y3qHNgE2AFweciB1IfMnmZ/fsNTEE+NOFjmGB2F/7rLhnhzppvpCnN4FovtP26k8lHyy9ptEbNwWFLzw=="
- },
- "cli-width": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.0.0.tgz",
- "integrity": "sha512-ZksGS2xpa/bYkNzN3BAw1wEjsLV/ZKOf/CCrJ/QOBsxx6fOARIkwTutxp1XIOIohi6HKmOFjMoK/XaqDVUpEEw=="
- },
- "clipboardy": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz",
- "integrity": "sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==",
- "requires": {
- "arch": "^2.2.0",
- "execa": "^5.1.1",
- "is-wsl": "^2.2.0"
- }
- },
- "clone": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
- "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="
- },
- "combined-stream": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
- "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
- "requires": {
- "delayed-stream": "~1.0.0"
- }
- },
- "concat-map": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
- "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
- "dev": true
- },
- "content-disposition": {
- "version": "0.5.4",
- "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
- "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
- "requires": {
- "safe-buffer": "5.2.1"
- }
- },
- "content-type": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
- "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="
- },
- "cookie": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
- "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="
- },
- "cookie-signature": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
- "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
- },
- "cors": {
- "version": "2.8.5",
- "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
- "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
- "requires": {
- "object-assign": "^4",
- "vary": "^1"
- }
- },
- "cross-spawn": {
- "version": "7.0.3",
- "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
- "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
- "requires": {
- "path-key": "^3.1.0",
- "shebang-command": "^2.0.0",
- "which": "^2.0.1"
- }
- },
- "debug": {
- "version": "4.3.4",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
- "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
- "requires": {
- "ms": "2.1.2"
- }
- },
- "defaults": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
- "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==",
- "requires": {
- "clone": "^1.0.2"
- }
- },
- "delayed-stream": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
- "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
- },
- "depd": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
- "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="
- },
- "destroy": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
- "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="
- },
- "dotenv": {
- "version": "16.0.3",
- "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz",
- "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ=="
- },
- "eastasianwidth": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
- "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
- },
- "ee-first": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
- "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
- },
- "emoji-regex": {
- "version": "9.2.2",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
- "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
- },
- "encodeurl": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
- "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="
- },
- "escape-html": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
- "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
- },
- "escape-string-regexp": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
- "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="
- },
- "etag": {
- "version": "1.8.1",
- "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
- "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="
- },
- "event-target-shim": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
- "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
- },
- "events": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
- "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="
- },
- "execa": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
- "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
- "requires": {
- "cross-spawn": "^7.0.3",
- "get-stream": "^6.0.0",
- "human-signals": "^2.1.0",
- "is-stream": "^2.0.0",
- "merge-stream": "^2.0.0",
- "npm-run-path": "^4.0.1",
- "onetime": "^5.1.2",
- "signal-exit": "^3.0.3",
- "strip-final-newline": "^2.0.0"
- }
- },
- "express": {
- "version": "4.18.2",
- "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
- "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==",
- "requires": {
- "accepts": "~1.3.8",
- "array-flatten": "1.1.1",
- "body-parser": "1.20.1",
- "content-disposition": "0.5.4",
- "content-type": "~1.0.4",
- "cookie": "0.5.0",
- "cookie-signature": "1.0.6",
- "debug": "2.6.9",
- "depd": "2.0.0",
- "encodeurl": "~1.0.2",
- "escape-html": "~1.0.3",
- "etag": "~1.8.1",
- "finalhandler": "1.2.0",
- "fresh": "0.5.2",
- "http-errors": "2.0.0",
- "merge-descriptors": "1.0.1",
- "methods": "~1.1.2",
- "on-finished": "2.4.1",
- "parseurl": "~1.3.3",
- "path-to-regexp": "0.1.7",
- "proxy-addr": "~2.0.7",
- "qs": "6.11.0",
- "range-parser": "~1.2.1",
- "safe-buffer": "5.2.1",
- "send": "0.18.0",
- "serve-static": "1.15.0",
- "setprototypeof": "1.2.0",
- "statuses": "2.0.1",
- "type-is": "~1.6.18",
- "utils-merge": "1.0.1",
- "vary": "~1.1.2"
- },
- "dependencies": {
- "debug": {
- "version": "2.6.9",
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "requires": {
- "ms": "2.0.0"
- }
- },
- "ms": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
- }
- }
- },
- "external-editor": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
- "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==",
- "requires": {
- "chardet": "^0.7.0",
- "iconv-lite": "^0.4.24",
- "tmp": "^0.0.33"
- }
- },
- "fast-content-type-parse": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.0.0.tgz",
- "integrity": "sha512-Xbc4XcysUXcsP5aHUU7Nq3OwvHq97C+WnbkeIefpeYLX+ryzFJlU6OStFJhs6Ol0LkUGpcK+wL0JwfM+FCU5IA=="
- },
- "fast-decode-uri-component": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
- "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="
- },
- "fast-deep-equal": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
- "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
- },
- "fast-fifo": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.1.0.tgz",
- "integrity": "sha512-Kl29QoNbNvn4nhDsLYjyIAaIqaJB6rBx5p3sL9VjaefJ+eMFBWVZiaoguaoZfzEKr5RhAti0UgM8703akGPJ6g=="
- },
- "fast-json-stringify": {
- "version": "5.6.2",
- "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.6.2.tgz",
- "integrity": "sha512-F6xkRrXvtGbAiDSEI5Rk7qk2P63Y9kc8bO6Dnsd3Rt6sBNr2QxNFWs0JbKftgiyOfGxnJaRoHe4SizCTqeAyrA==",
- "requires": {
- "@fastify/deepmerge": "^1.0.0",
- "ajv": "^8.10.0",
- "ajv-formats": "^2.1.1",
- "fast-deep-equal": "^3.1.3",
- "fast-uri": "^2.1.0",
- "rfdc": "^1.2.0"
- }
- },
- "fast-querystring": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.1.tgz",
- "integrity": "sha512-qR2r+e3HvhEFmpdHMv//U8FnFlnYjaC6QKDuaXALDkw2kvHO8WDjxH+f/rHGR4Me4pnk8p9JAkRNTjYHAKRn2Q==",
- "requires": {
- "fast-decode-uri-component": "^1.0.1"
- }
- },
- "fast-redact": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.1.2.tgz",
- "integrity": "sha512-+0em+Iya9fKGfEQGcd62Yv6onjBmmhV1uh86XVfOU8VwAe6kaFdQCWI9s0/Nnugx5Vd9tdbZ7e6gE2tR9dzXdw=="
- },
- "fast-uri": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.2.0.tgz",
- "integrity": "sha512-cIusKBIt/R/oI6z/1nyfe2FvGKVTohVRfvkOhvx0nCEW+xf5NoCXjAHcWp93uOUBchzYcsvPlrapAdX1uW+YGg=="
- },
- "fast-xml-parser": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.1.2.tgz",
- "integrity": "sha512-CDYeykkle1LiA/uqQyNwYpFbyF6Axec6YapmpUP+/RHWIoR1zKjocdvNaTsxCxZzQ6v9MLXaSYm9Qq0thv0DHg==",
- "optional": true,
- "requires": {
- "strnum": "^1.0.5"
- }
- },
- "fastify": {
- "version": "4.14.1",
- "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.14.1.tgz",
- "integrity": "sha512-yjrDeXe77j9gRlSV2UJry8mcFWbD0NQ5JYjnPi4tkFjHZVaG3/BD5wxOmRzGnHPC0YvaBJ0XWrIfFPl2IHRa1w==",
- "requires": {
- "@fastify/ajv-compiler": "^3.5.0",
- "@fastify/error": "^3.0.0",
- "@fastify/fast-json-stringify-compiler": "^4.1.0",
- "abstract-logging": "^2.0.1",
- "avvio": "^8.2.0",
- "fast-content-type-parse": "^1.0.0",
- "find-my-way": "^7.3.0",
- "light-my-request": "^5.6.1",
- "pino": "^8.5.0",
- "process-warning": "^2.0.0",
- "proxy-addr": "^2.0.7",
- "rfdc": "^1.3.0",
- "secure-json-parse": "^2.5.0",
- "semver": "^7.3.7",
- "tiny-lru": "^10.0.0"
- }
- },
- "fastify-plugin": {
- "version": "4.5.0",
- "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.0.tgz",
- "integrity": "sha512-79ak0JxddO0utAXAQ5ccKhvs6vX2MGyHHMMsmZkBANrq3hXc1CHzvNPHOcvTsVMEPl5I+NT+RO4YKMGehOfSIg=="
- },
- "fastq": {
- "version": "1.15.0",
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
- "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==",
- "requires": {
- "reusify": "^1.0.4"
- }
- },
- "fetch-undici": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/fetch-undici/-/fetch-undici-3.0.1.tgz",
- "integrity": "sha512-UHHu1HqW22ZhK6C/1Zmjf7mQpOwPwLYZ+xcsOgpzistONU8QqvCop6Od29p/kw1GUVoq2Ihu6ItpKLtlojx4FQ==",
- "requires": {
- "undici": "^5.0.0"
- }
- },
- "figures": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/figures/-/figures-5.0.0.tgz",
- "integrity": "sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==",
- "requires": {
- "escape-string-regexp": "^5.0.0",
- "is-unicode-supported": "^1.2.0"
- }
- },
- "fill-range": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
- "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
- "dev": true,
- "requires": {
- "to-regex-range": "^5.0.1"
- }
- },
- "finalhandler": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz",
- "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==",
- "requires": {
- "debug": "2.6.9",
- "encodeurl": "~1.0.2",
- "escape-html": "~1.0.3",
- "on-finished": "2.4.1",
- "parseurl": "~1.3.3",
- "statuses": "2.0.1",
- "unpipe": "~1.0.0"
- },
- "dependencies": {
- "debug": {
- "version": "2.6.9",
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "requires": {
- "ms": "2.0.0"
- }
- },
- "ms": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
- }
- }
- },
- "find-my-way": {
- "version": "7.5.0",
- "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-7.5.0.tgz",
- "integrity": "sha512-3ehydSBhGcS0TtMA/BYEyMAKi9Sv0MqF8aqiMO5oGBXyCcSlyEJyfGWsbNxAx7BekTNWUwD1ttLJLURni2vmJg==",
- "requires": {
- "fast-deep-equal": "^3.1.3",
- "fast-querystring": "^1.0.0",
- "safe-regex2": "^2.0.0"
- }
- },
- "follow-redirects": {
- "version": "1.15.2",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
- "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA=="
- },
- "form-data": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
- "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
- "requires": {
- "asynckit": "^0.4.0",
- "combined-stream": "^1.0.8",
- "mime-types": "^2.1.12"
- }
- },
- "forwarded": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
- "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="
- },
- "fresh": {
- "version": "0.5.2",
- "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
- "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="
- },
- "fs-extra": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.3.tgz",
- "integrity": "sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==",
- "requires": {
- "graceful-fs": "^4.1.2",
- "jsonfile": "^4.0.0",
- "universalify": "^0.1.0"
- }
- },
- "fsevents": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
- "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "dev": true,
- "optional": true
- },
- "function-bind": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
- "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
- },
- "get-intrinsic": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz",
- "integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==",
- "requires": {
- "function-bind": "^1.1.1",
- "has": "^1.0.3",
- "has-symbols": "^1.0.3"
- }
- },
- "get-iterator": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/get-iterator/-/get-iterator-1.0.2.tgz",
- "integrity": "sha512-v+dm9bNVfOYsY1OrhaCrmyOcYoSeVvbt+hHZ0Au+T+p1y+0Uyj9aMaGIeUTT6xdpRbWzDeYKvfOslPhggQMcsg=="
- },
- "get-stream": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
- "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="
- },
- "glob-parent": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
- "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "dev": true,
- "requires": {
- "is-glob": "^4.0.1"
- }
- },
- "graceful-fs": {
- "version": "4.2.10",
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
- "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="
- },
- "has": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
- "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
- "requires": {
- "function-bind": "^1.1.1"
- }
- },
- "has-flag": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
- "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
- "dev": true
- },
- "has-symbols": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
- "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A=="
- },
- "http-errors": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
- "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
- "requires": {
- "depd": "2.0.0",
- "inherits": "2.0.4",
- "setprototypeof": "1.2.0",
- "statuses": "2.0.1",
- "toidentifier": "1.0.1"
- }
- },
- "https-proxy-agent": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
- "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
- "requires": {
- "agent-base": "6",
- "debug": "4"
- }
- },
- "human-signals": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
- "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="
- },
- "iconv-lite": {
- "version": "0.4.24",
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
- "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
- "requires": {
- "safer-buffer": ">= 2.1.2 < 3"
- }
- },
- "ieee754": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
- "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
- },
- "ignore-by-default": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
- "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
- "dev": true
- },
- "inherits": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
- "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
- },
- "inquirer": {
- "version": "9.1.4",
- "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.1.4.tgz",
- "integrity": "sha512-9hiJxE5gkK/cM2d1mTEnuurGTAoHebbkX0BYl3h7iEg7FYfuNIom+nDfBCSWtvSnoSrWCeBxqqBZu26xdlJlXA==",
- "requires": {
- "ansi-escapes": "^6.0.0",
- "chalk": "^5.1.2",
- "cli-cursor": "^4.0.0",
- "cli-width": "^4.0.0",
- "external-editor": "^3.0.3",
- "figures": "^5.0.0",
- "lodash": "^4.17.21",
- "mute-stream": "0.0.8",
- "ora": "^6.1.2",
- "run-async": "^2.4.0",
- "rxjs": "^7.5.7",
- "string-width": "^5.1.2",
- "strip-ansi": "^7.0.1",
- "through": "^2.3.6",
- "wrap-ansi": "^8.0.1"
- }
- },
- "inquirer-autocomplete-prompt": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/inquirer-autocomplete-prompt/-/inquirer-autocomplete-prompt-3.0.0.tgz",
- "integrity": "sha512-nsPWllBQB3qhvpVgV1UIJN4xo3yz7Qv8y1+zrNVpJUNPxtUZ7btCum/4UCAs5apPCe/FVhKH1V6Wx0cAwkreyg==",
- "requires": {
- "ansi-escapes": "^6.0.0",
- "figures": "^5.0.0",
- "picocolors": "^1.0.0",
- "run-async": "^2.4.1",
- "rxjs": "^7.5.6"
- }
- },
- "ip": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz",
- "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ=="
- },
- "ipaddr.js": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
- "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="
- },
- "is-binary-path": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
- "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
- "dev": true,
- "requires": {
- "binary-extensions": "^2.0.0"
- }
- },
- "is-docker": {
- "version": "2.2.1",
- "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
- "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="
- },
- "is-extglob": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
- "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
- "dev": true
- },
- "is-fullwidth-code-point": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
- "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="
- },
- "is-glob": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
- "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
- "dev": true,
- "requires": {
- "is-extglob": "^2.1.1"
- }
- },
- "is-interactive": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz",
- "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="
- },
- "is-number": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
- "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
- "dev": true
- },
- "is-stream": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
- "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="
- },
- "is-unicode-supported": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz",
- "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="
- },
- "is-wsl": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
- "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
- "requires": {
- "is-docker": "^2.0.0"
- }
- },
- "isexe": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
- "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
- },
- "it-pushable": {
- "version": "1.4.2",
- "resolved": "https://registry.npmjs.org/it-pushable/-/it-pushable-1.4.2.tgz",
- "integrity": "sha512-vVPu0CGRsTI8eCfhMknA7KIBqqGFolbRx+1mbQ6XuZ7YCz995Qj7L4XUviwClFunisDq96FdxzF5FnAbw15afg==",
- "requires": {
- "fast-fifo": "^1.0.0"
- }
- },
- "it-to-stream": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/it-to-stream/-/it-to-stream-1.0.0.tgz",
- "integrity": "sha512-pLULMZMAB/+vbdvbZtebC0nWBTbG581lk6w8P7DfIIIKUfa8FbY7Oi0FxZcFPbxvISs7A9E+cMpLDBc1XhpAOA==",
- "requires": {
- "buffer": "^6.0.3",
- "fast-fifo": "^1.0.0",
- "get-iterator": "^1.0.2",
- "p-defer": "^3.0.0",
- "p-fifo": "^1.0.0",
- "readable-stream": "^3.6.0"
- }
- },
- "json-buffer": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
- "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="
- },
- "json-schema-traverse": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
- "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
- },
- "jsonfile": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
- "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
- "requires": {
- "graceful-fs": "^4.1.6"
- }
- },
- "kareem": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz",
- "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA=="
- },
- "keyv": {
- "version": "4.5.2",
- "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.2.tgz",
- "integrity": "sha512-5MHbFaKn8cNSmVW7BYnijeAVlE4cYA/SVkifVgrh7yotnfhKmjuXpDKjrABLnT0SfHWV21P8ow07OGfRrNDg8g==",
- "requires": {
- "json-buffer": "3.0.1"
- }
- },
- "keyv-file": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/keyv-file/-/keyv-file-0.2.0.tgz",
- "integrity": "sha512-zUQ11eZRmilEUpV1gJSj8mBAHjyXpleQo1iCS0khb+GFRhiPfwavWgn4eDUKNlOyMZzmExnISl8HE1hNbim0gw==",
- "requires": {
- "debug": "^4.1.1",
- "fs-extra": "^4.0.1",
- "tslib": "^1.9.3"
- }
- },
- "light-my-request": {
- "version": "5.9.1",
- "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.9.1.tgz",
- "integrity": "sha512-UT7pUk8jNCR1wR7w3iWfIjx32DiB2f3hFdQSOwy3/EPQ3n3VocyipUxcyRZR0ahoev+fky69uA+GejPa9KuHKg==",
- "requires": {
- "cookie": "^0.5.0",
- "process-warning": "^2.0.0",
- "set-cookie-parser": "^2.4.1"
- }
- },
- "lodash": {
- "version": "4.17.21",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
- },
- "log-symbols": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-5.1.0.tgz",
- "integrity": "sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==",
- "requires": {
- "chalk": "^5.0.0",
- "is-unicode-supported": "^1.1.0"
- }
- },
- "lru-cache": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
- "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
- "requires": {
- "yallist": "^4.0.0"
- }
- },
- "media-typer": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
- "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="
- },
- "memory-pager": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
- "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
- "optional": true
- },
- "merge-descriptors": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
- "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w=="
- },
- "merge-stream": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
- "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
- },
- "methods": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
- "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="
- },
- "mime": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
- "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="
- },
- "mime-db": {
- "version": "1.52.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
- "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
- },
- "mime-types": {
- "version": "2.1.35",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
- "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
- "requires": {
- "mime-db": "1.52.0"
- }
- },
- "mimic-fn": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
- "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="
- },
- "minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
- "dev": true,
- "requires": {
- "brace-expansion": "^1.1.7"
- }
- },
- "mnemonist": {
- "version": "0.39.5",
- "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.5.tgz",
- "integrity": "sha512-FPUtkhtJ0efmEFGpU14x7jGbTB+s18LrzRL2KgoWz9YvcY3cPomz8tih01GbHwnGk/OmkOKfqd/RAQoc8Lm7DQ==",
- "requires": {
- "obliterator": "^2.0.1"
- }
- },
- "mongodb": {
- "version": "4.14.0",
- "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.14.0.tgz",
- "integrity": "sha512-coGKkWXIBczZPr284tYKFLg+KbGPPLlSbdgfKAb6QqCFt5bo5VFZ50O3FFzsw4rnkqjwT6D8Qcoo9nshYKM7Mg==",
- "requires": {
- "@aws-sdk/credential-providers": "^3.186.0",
- "bson": "^4.7.0",
- "mongodb-connection-string-url": "^2.5.4",
- "saslprep": "^1.0.3",
- "socks": "^2.7.1"
- }
- },
- "mongodb-connection-string-url": {
- "version": "2.6.0",
- "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz",
- "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==",
- "requires": {
- "@types/whatwg-url": "^8.2.1",
- "whatwg-url": "^11.0.0"
- }
- },
- "mongoose": {
- "version": "6.10.1",
- "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-6.10.1.tgz",
- "integrity": "sha512-QBXX27XXCEIi1zqimTBjlZsFFg2YzK9uQfuAIqG1AyIEeyKiiCaobD0Tst2ULwiX4PRB53VLRQvAqyu5TwvOZg==",
- "requires": {
- "bson": "^4.7.0",
- "kareem": "2.5.1",
- "mongodb": "4.14.0",
- "mpath": "0.9.0",
- "mquery": "4.0.3",
- "ms": "2.1.3",
- "sift": "16.0.1"
- },
- "dependencies": {
- "ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
- }
- }
- },
- "mpath": {
- "version": "0.9.0",
- "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
- "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew=="
- },
- "mquery": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/mquery/-/mquery-4.0.3.tgz",
- "integrity": "sha512-J5heI+P08I6VJ2Ky3+33IpCdAvlYGTSUjwTPxkAr8i8EoduPMBX2OY/wa3IKZIQl7MU4SbFk8ndgSKyB/cl1zA==",
- "requires": {
- "debug": "4.x"
- }
- },
- "ms": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
- "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
- },
- "mute-stream": {
- "version": "0.0.8",
- "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
- "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="
- },
- "negotiator": {
- "version": "0.6.3",
- "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
- "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="
- },
- "nodemon": {
- "version": "2.0.21",
- "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.21.tgz",
- "integrity": "sha512-djN/n2549DUtY33S7o1djRCd7dEm0kBnj9c7S9XVXqRUbuggN1MZH/Nqa+5RFQr63Fbefq37nFXAE9VU86yL1A==",
- "dev": true,
- "requires": {
- "chokidar": "^3.5.2",
- "debug": "^3.2.7",
- "ignore-by-default": "^1.0.1",
- "minimatch": "^3.1.2",
- "pstree.remy": "^1.1.8",
- "semver": "^5.7.1",
- "simple-update-notifier": "^1.0.7",
- "supports-color": "^5.5.0",
- "touch": "^3.1.0",
- "undefsafe": "^2.0.5"
- },
- "dependencies": {
- "debug": {
- "version": "3.2.7",
- "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
- "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
- "dev": true,
- "requires": {
- "ms": "^2.1.1"
- }
- },
- "semver": {
- "version": "5.7.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
- "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
- "dev": true
- }
- }
- },
- "nopt": {
- "version": "1.0.10",
- "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz",
- "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==",
- "dev": true,
- "requires": {
- "abbrev": "1"
- }
- },
- "normalize-path": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
- "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
- "dev": true
- },
- "npm-run-path": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
- "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
- "requires": {
- "path-key": "^3.0.0"
- }
- },
- "object-assign": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
- "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
- },
- "object-inspect": {
- "version": "1.12.3",
- "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz",
- "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g=="
- },
- "obliterator": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.4.tgz",
- "integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ=="
- },
- "on-exit-leak-free": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.0.tgz",
- "integrity": "sha512-VuCaZZAjReZ3vUwgOB8LxAosIurDiAW0s13rI1YwmaP++jvcxP77AWoQvenZebpCA2m8WC1/EosPYPMjnRAp/w=="
- },
- "on-finished": {
- "version": "2.4.1",
- "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
- "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
- "requires": {
- "ee-first": "1.1.1"
- }
- },
- "onetime": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
- "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
- "requires": {
- "mimic-fn": "^2.1.0"
- }
- },
- "openai": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/openai/-/openai-3.2.1.tgz",
- "integrity": "sha512-762C9BNlJPbjjlWZi4WYK9iM2tAVAv0uUp1UmI34vb0CN5T2mjB/qM6RYBmNKMh/dN9fC+bxqPwWJZUTWW052A==",
- "requires": {
- "axios": "^0.26.0",
- "form-data": "^4.0.0"
- }
- },
- "ora": {
- "version": "6.1.2",
- "resolved": "https://registry.npmjs.org/ora/-/ora-6.1.2.tgz",
- "integrity": "sha512-EJQ3NiP5Xo94wJXIzAyOtSb0QEIAUu7m8t6UZ9krbz0vAJqr92JpcK/lEXg91q6B9pEGqrykkd2EQplnifDSBw==",
- "requires": {
- "bl": "^5.0.0",
- "chalk": "^5.0.0",
- "cli-cursor": "^4.0.0",
- "cli-spinners": "^2.6.1",
- "is-interactive": "^2.0.0",
- "is-unicode-supported": "^1.1.0",
- "log-symbols": "^5.1.0",
- "strip-ansi": "^7.0.1",
- "wcwidth": "^1.0.1"
- }
- },
- "os-tmpdir": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
- "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="
- },
- "p-defer": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-3.0.0.tgz",
- "integrity": "sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw=="
- },
- "p-fifo": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/p-fifo/-/p-fifo-1.0.0.tgz",
- "integrity": "sha512-IjoCxXW48tqdtDFz6fqo5q1UfFVjjVZe8TC1QRflvNUJtNfCUhxOUw6MOVZhDPjqhSzc26xKdugsO17gmzd5+A==",
- "requires": {
- "fast-fifo": "^1.0.0",
- "p-defer": "^3.0.0"
- }
- },
- "parseurl": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
- "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="
- },
- "path": {
- "version": "0.12.7",
- "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz",
- "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==",
- "dev": true,
- "requires": {
- "process": "^0.11.1",
- "util": "^0.10.3"
- }
- },
- "path-key": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
- "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="
- },
- "path-to-regexp": {
- "version": "0.1.7",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
- "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ=="
- },
- "picocolors": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
- "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="
- },
- "picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
- "dev": true
- },
- "pify": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz",
- "integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA=="
- },
- "pino": {
- "version": "8.11.0",
- "resolved": "https://registry.npmjs.org/pino/-/pino-8.11.0.tgz",
- "integrity": "sha512-Z2eKSvlrl2rH8p5eveNUnTdd4AjJk8tAsLkHYZQKGHP4WTh2Gi1cOSOs3eWPqaj+niS3gj4UkoreoaWgF3ZWYg==",
- "requires": {
- "atomic-sleep": "^1.0.0",
- "fast-redact": "^3.1.1",
- "on-exit-leak-free": "^2.1.0",
- "pino-abstract-transport": "v1.0.0",
- "pino-std-serializers": "^6.0.0",
- "process-warning": "^2.0.0",
- "quick-format-unescaped": "^4.0.3",
- "real-require": "^0.2.0",
- "safe-stable-stringify": "^2.3.1",
- "sonic-boom": "^3.1.0",
- "thread-stream": "^2.0.0"
- }
- },
- "pino-abstract-transport": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.0.0.tgz",
- "integrity": "sha512-c7vo5OpW4wIS42hUVcT5REsL8ZljsUfBjqV/e2sFxmFEFZiq1XLUp5EYLtuDH6PEHq9W1egWqRbnLUP5FuZmOA==",
- "requires": {
- "readable-stream": "^4.0.0",
- "split2": "^4.0.0"
- },
- "dependencies": {
- "readable-stream": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.3.0.tgz",
- "integrity": "sha512-MuEnA0lbSi7JS8XM+WNJlWZkHAAdm7gETHdFK//Q/mChGyj2akEFtdLZh32jSdkWGbRwCW9pn6g3LWDdDeZnBQ==",
- "requires": {
- "abort-controller": "^3.0.0",
- "buffer": "^6.0.3",
- "events": "^3.3.0",
- "process": "^0.11.10"
- }
- }
- }
- },
- "pino-std-serializers": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-6.1.0.tgz",
- "integrity": "sha512-KO0m2f1HkrPe9S0ldjx7za9BJjeHqBku5Ch8JyxETxT8dEFGz1PwgrHaOQupVYitpzbFSYm7nnljxD8dik2c+g=="
- },
- "process": {
- "version": "0.11.10",
- "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
- "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="
- },
- "process-warning": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-2.1.0.tgz",
- "integrity": "sha512-9C20RLxrZU/rFnxWncDkuF6O999NdIf3E1ws4B0ZeY3sRVPzWBMsYDE2lxjxhiXxg464cQTgKUGm8/i6y2YGXg=="
- },
- "proxy-addr": {
- "version": "2.0.7",
- "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
- "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
- "requires": {
- "forwarded": "0.2.0",
- "ipaddr.js": "1.9.1"
- }
- },
- "pstree.remy": {
- "version": "1.1.8",
- "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
- "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
- "dev": true
- },
- "punycode": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz",
- "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA=="
- },
- "qs": {
- "version": "6.11.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
- "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==",
- "requires": {
- "side-channel": "^1.0.4"
- }
- },
- "quick-format-unescaped": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
- "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
- },
- "range-parser": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
- "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="
- },
- "raw-body": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
- "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==",
- "requires": {
- "bytes": "3.1.2",
- "http-errors": "2.0.0",
- "iconv-lite": "0.4.24",
- "unpipe": "1.0.0"
- }
- },
- "readable-stream": {
- "version": "3.6.1",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.1.tgz",
- "integrity": "sha512-+rQmrWMYGA90yenhTYsLWAsLsqVC8osOw6PKE1HDYiO0gdPeKe/xDHNzIAIn4C91YQ6oenEhfYqqc1883qHbjQ==",
- "requires": {
- "inherits": "^2.0.3",
- "string_decoder": "^1.1.1",
- "util-deprecate": "^1.0.1"
- }
- },
- "readdirp": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
- "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
- "dev": true,
- "requires": {
- "picomatch": "^2.2.1"
- }
- },
- "real-require": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
- "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="
- },
- "require-from-string": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
- "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="
- },
- "restore-cursor": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
- "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==",
- "requires": {
- "onetime": "^5.1.0",
- "signal-exit": "^3.0.2"
- }
- },
- "ret": {
- "version": "0.2.2",
- "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz",
- "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ=="
- },
- "reusify": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
- "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="
- },
- "rfdc": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz",
- "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA=="
- },
- "run-async": {
- "version": "2.4.1",
- "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz",
- "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ=="
- },
- "rxjs": {
- "version": "7.8.0",
- "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.0.tgz",
- "integrity": "sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==",
- "requires": {
- "tslib": "^2.1.0"
- },
- "dependencies": {
- "tslib": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
- "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg=="
- }
- }
- },
- "safe-buffer": {
- "version": "5.2.1",
- "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
- "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
- },
- "safe-regex2": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-2.0.0.tgz",
- "integrity": "sha512-PaUSFsUaNNuKwkBijoAPHAK6/eM6VirvyPWlZ7BAQy4D+hCvh4B6lIG+nPdhbFfIbP+gTGBcrdsOaUs0F+ZBOQ==",
- "requires": {
- "ret": "~0.2.0"
- }
- },
- "safe-stable-stringify": {
- "version": "2.4.2",
- "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.2.tgz",
- "integrity": "sha512-gMxvPJYhP0O9n2pvcfYfIuYgbledAOJFcqRThtPRmjscaipiwcwPPKLytpVzMkG2HAN87Qmo2d4PtGiri1dSLA=="
- },
- "safer-buffer": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
- "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
- },
- "saslprep": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz",
- "integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==",
- "optional": true,
- "requires": {
- "sparse-bitfield": "^3.0.3"
- }
- },
- "secure-json-parse": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
- "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="
- },
- "semver": {
- "version": "7.3.8",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
- "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
- "requires": {
- "lru-cache": "^6.0.0"
- }
- },
- "send": {
- "version": "0.18.0",
- "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz",
- "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==",
- "requires": {
- "debug": "2.6.9",
- "depd": "2.0.0",
- "destroy": "1.2.0",
- "encodeurl": "~1.0.2",
- "escape-html": "~1.0.3",
- "etag": "~1.8.1",
- "fresh": "0.5.2",
- "http-errors": "2.0.0",
- "mime": "1.6.0",
- "ms": "2.1.3",
- "on-finished": "2.4.1",
- "range-parser": "~1.2.1",
- "statuses": "2.0.1"
- },
- "dependencies": {
- "debug": {
- "version": "2.6.9",
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "requires": {
- "ms": "2.0.0"
- },
- "dependencies": {
- "ms": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
- }
- }
- },
- "ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
- }
- }
- },
- "serve-static": {
- "version": "1.15.0",
- "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz",
- "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==",
- "requires": {
- "encodeurl": "~1.0.2",
- "escape-html": "~1.0.3",
- "parseurl": "~1.3.3",
- "send": "0.18.0"
- }
- },
- "set-cookie-parser": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.5.1.tgz",
- "integrity": "sha512-1jeBGaKNGdEq4FgIrORu/N570dwoPYio8lSoYLWmX7sQ//0JY08Xh9o5pBcgmHQ/MbsYp/aZnOe1s1lIsbLprQ=="
- },
- "setprototypeof": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
- "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
- },
- "shebang-command": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
- "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
- "requires": {
- "shebang-regex": "^3.0.0"
- }
- },
- "shebang-regex": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
- "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="
- },
- "side-channel": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
- "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
- "requires": {
- "call-bind": "^1.0.0",
- "get-intrinsic": "^1.0.2",
- "object-inspect": "^1.9.0"
- }
- },
- "sift": {
- "version": "16.0.1",
- "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz",
- "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ=="
- },
- "signal-exit": {
- "version": "3.0.7",
- "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
- "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
- },
- "simple-update-notifier": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz",
- "integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==",
- "dev": true,
- "requires": {
- "semver": "~7.0.0"
- },
- "dependencies": {
- "semver": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz",
- "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==",
- "dev": true
- }
- }
- },
- "smart-buffer": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
- "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="
- },
- "socks": {
- "version": "2.7.1",
- "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz",
- "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==",
- "requires": {
- "ip": "^2.0.0",
- "smart-buffer": "^4.2.0"
- }
- },
- "sonic-boom": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.2.1.tgz",
- "integrity": "sha512-iITeTHxy3B9FGu8aVdiDXUVAcHMF9Ss0cCsAOo2HfCrmVGT3/DT5oYaeu0M/YKZDlKTvChEyPq0zI9Hf33EX6A==",
- "requires": {
- "atomic-sleep": "^1.0.0"
- }
- },
- "sparse-bitfield": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
- "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
- "optional": true,
- "requires": {
- "memory-pager": "^1.0.2"
- }
- },
- "split2": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz",
- "integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ=="
- },
- "statuses": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
- "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="
- },
- "streamsearch": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
- "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="
- },
- "string_decoder": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
- "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
- "requires": {
- "safe-buffer": "~5.2.0"
- }
- },
- "string-width": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
- "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
- "requires": {
- "eastasianwidth": "^0.2.0",
- "emoji-regex": "^9.2.2",
- "strip-ansi": "^7.0.1"
- }
- },
- "strip-ansi": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.0.1.tgz",
- "integrity": "sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==",
- "requires": {
- "ansi-regex": "^6.0.1"
- }
- },
- "strip-final-newline": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
- "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="
- },
- "strnum": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz",
- "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==",
- "optional": true
- },
- "supports-color": {
- "version": "5.5.0",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
- "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
- "dev": true,
- "requires": {
- "has-flag": "^3.0.0"
- }
- },
- "thread-stream": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.3.0.tgz",
- "integrity": "sha512-kaDqm1DET9pp3NXwR8382WHbnpXnRkN9xGN9dQt3B2+dmXiW8X1SOwmFOxAErEQ47ObhZ96J6yhZNXuyCOL7KA==",
- "requires": {
- "real-require": "^0.2.0"
- }
- },
- "through": {
- "version": "2.3.8",
- "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
- "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
- },
- "tiny-lru": {
- "version": "10.0.1",
- "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-10.0.1.tgz",
- "integrity": "sha512-Vst+6kEsWvb17Zpz14sRJV/f8bUWKhqm6Dc+v08iShmIJ/WxqWytHzCTd6m88pS33rE2zpX34TRmOpAJPloNCA=="
- },
- "tmp": {
- "version": "0.0.33",
- "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
- "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
- "requires": {
- "os-tmpdir": "~1.0.2"
- }
- },
- "to-regex-range": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
- "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
- "dev": true,
- "requires": {
- "is-number": "^7.0.0"
- }
- },
- "toidentifier": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
- "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="
- },
- "touch": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz",
- "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==",
- "dev": true,
- "requires": {
- "nopt": "~1.0.10"
- }
- },
- "tr46": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz",
- "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==",
- "requires": {
- "punycode": "^2.1.1"
- }
- },
- "tslib": {
- "version": "1.14.1",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
- "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
- },
- "type-fest": {
- "version": "2.19.0",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
- "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="
- },
- "type-is": {
- "version": "1.6.18",
- "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
- "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
- "requires": {
- "media-typer": "0.3.0",
- "mime-types": "~2.1.24"
- }
- },
- "undefsafe": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
- "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
- "dev": true
- },
- "undici": {
- "version": "5.20.0",
- "resolved": "https://registry.npmjs.org/undici/-/undici-5.20.0.tgz",
- "integrity": "sha512-J3j60dYzuo6Eevbawwp1sdg16k5Tf768bxYK4TUJRH7cBM4kFCbf3mOnM/0E3vQYXvpxITbbWmBafaDbxLDz3g==",
- "requires": {
- "busboy": "^1.6.0"
- }
- },
- "universalify": {
- "version": "0.1.2",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
- "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="
- },
- "unpipe": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
- "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="
- },
- "uri-js": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
- "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
- "requires": {
- "punycode": "^2.1.0"
- }
- },
- "util": {
- "version": "0.10.4",
- "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz",
- "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==",
- "dev": true,
- "requires": {
- "inherits": "2.0.3"
- },
- "dependencies": {
- "inherits": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
- "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
- "dev": true
- }
- }
- },
- "util-deprecate": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
- "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
- },
- "utils-merge": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
- "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="
- },
- "uuid": {
- "version": "8.3.2",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
- "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
- "optional": true
- },
- "vary": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
- "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="
- },
- "wcwidth": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
- "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==",
- "requires": {
- "defaults": "^1.0.3"
- }
- },
- "webidl-conversions": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
- "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="
- },
- "whatwg-url": {
- "version": "11.0.0",
- "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz",
- "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==",
- "requires": {
- "tr46": "^3.0.0",
- "webidl-conversions": "^7.0.0"
- }
- },
- "which": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
- "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "requires": {
- "isexe": "^2.0.0"
- }
- },
- "widest-line": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz",
- "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==",
- "requires": {
- "string-width": "^5.0.1"
- }
- },
- "wrap-ansi": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
- "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
- "requires": {
- "ansi-styles": "^6.1.0",
- "string-width": "^5.0.1",
- "strip-ansi": "^7.0.1"
- }
- },
- "ws": {
- "version": "8.12.1",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.12.1.tgz",
- "integrity": "sha512-1qo+M9Ba+xNhPB+YTWUlK6M17brTut5EXbcBaMRN5pH5dFrXz7lzz1ChFSUq3bOUl8yEvSenhHmYUNJxFzdJew==",
- "requires": {}
- },
- "yallist": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
- }
- }
-}
diff --git a/api/package.json b/api/package.json
index 28bd6769d3..1e24fe585b 100644
--- a/api/package.json
+++ b/api/package.json
@@ -1,37 +1,120 @@
{
- "name": "chatgpt-clone",
- "version": "1.0.0",
+ "name": "@librechat/backend",
+ "version": "v0.8.2-rc1",
"description": "",
- "main": "server/index.js",
"scripts": {
- "start": "node server/index.js",
- "server-dev": "npx nodemon server/index.js"
+ "start": "echo 'please run this from the root directory'",
+ "server-dev": "echo 'please run this from the root directory'",
+ "test": "cross-env NODE_ENV=test jest",
+ "b:test": "NODE_ENV=test bun jest",
+ "test:ci": "jest --ci",
+ "add-balance": "node ./add-balance.js",
+ "list-balances": "node ./list-balances.js",
+ "user-stats": "node ./user-stats.js",
+ "create-user": "node ./create-user.js",
+ "invite-user": "node ./invite-user.js",
+ "ban-user": "node ./ban-user.js",
+ "delete-user": "node ./delete-user.js"
},
"repository": {
"type": "git",
- "url": "git+https://github.com/danny-avila/chatgpt-clone.git"
+ "url": "git+https://github.com/danny-avila/LibreChat.git"
},
"keywords": [],
"author": "",
"license": "ISC",
- "bugs": {
- "url": "https://github.com/danny-avila/chatgpt-clone/issues"
+ "_moduleAliases": {
+ "~": "."
},
- "homepage": "https://github.com/danny-avila/chatgpt-clone#readme",
+ "imports": {
+ "~/*": "./*"
+ },
+ "bugs": {
+ "url": "https://github.com/danny-avila/LibreChat/issues"
+ },
+ "homepage": "https://librechat.ai",
"dependencies": {
- "@keyv/mongo": "^2.1.8",
- "@vscode/vscode-languagedetection": "^1.0.22",
- "@waylaidwanderer/chatgpt-api": "^1.28.2",
+ "@aws-sdk/client-bedrock-runtime": "^3.941.0",
+ "@aws-sdk/client-s3": "^3.758.0",
+ "@aws-sdk/s3-request-presigner": "^3.758.0",
+ "@azure/identity": "^4.7.0",
+ "@azure/search-documents": "^12.0.0",
+ "@azure/storage-blob": "^12.27.0",
+ "@googleapis/youtube": "^20.0.0",
+ "@keyv/redis": "^4.3.3",
+ "@langchain/core": "^0.3.79",
+ "@librechat/agents": "^3.0.51",
+ "@librechat/api": "*",
+ "@librechat/data-schemas": "*",
+ "@microsoft/microsoft-graph-client": "^3.0.7",
+ "@modelcontextprotocol/sdk": "^1.24.3",
+ "@node-saml/passport-saml": "^5.1.0",
+ "@smithy/node-http-handler": "^4.4.5",
+ "axios": "^1.12.1",
+ "bcryptjs": "^2.4.3",
+ "compression": "^1.8.1",
+ "connect-redis": "^8.1.0",
+ "cookie": "^0.7.2",
+ "cookie-parser": "^1.4.7",
"cors": "^2.8.5",
+ "dedent": "^1.5.3",
"dotenv": "^16.0.3",
- "express": "^4.18.2",
- "keyv": "^4.5.2",
- "keyv-file": "^0.2.0",
- "mongoose": "^6.9.0",
- "openai": "^3.1.0"
+ "eventsource": "^3.0.2",
+ "express": "^5.1.0",
+ "express-mongo-sanitize": "^2.2.0",
+ "express-rate-limit": "^8.2.1",
+ "express-session": "^1.18.2",
+ "express-static-gzip": "^2.2.0",
+ "file-type": "^18.7.0",
+ "firebase": "^11.0.2",
+ "form-data": "^4.0.4",
+ "handlebars": "^4.7.7",
+ "https-proxy-agent": "^7.0.6",
+ "ioredis": "^5.3.2",
+ "js-yaml": "^4.1.1",
+ "jsonwebtoken": "^9.0.0",
+ "jwks-rsa": "^3.2.0",
+ "keyv": "^5.3.2",
+ "keyv-file": "^5.1.2",
+ "klona": "^2.0.6",
+ "librechat-data-provider": "*",
+ "lodash": "^4.17.21",
+ "meilisearch": "^0.38.0",
+ "memorystore": "^1.6.7",
+ "mime": "^3.0.0",
+ "module-alias": "^2.2.3",
+ "mongoose": "^8.12.1",
+ "multer": "^2.0.2",
+ "nanoid": "^3.3.7",
+ "node-fetch": "^2.7.0",
+ "nodemailer": "^7.0.11",
+ "ollama": "^0.5.0",
+ "openai": "5.8.2",
+ "openid-client": "^6.5.0",
+ "passport": "^0.6.0",
+ "passport-apple": "^2.0.2",
+ "passport-discord": "^0.1.4",
+ "passport-facebook": "^3.0.0",
+ "passport-github2": "^0.1.12",
+ "passport-google-oauth20": "^2.0.0",
+ "passport-jwt": "^4.0.1",
+ "passport-ldapauth": "^3.0.1",
+ "passport-local": "^1.0.0",
+ "rate-limit-redis": "^4.2.0",
+ "sharp": "^0.33.5",
+ "tiktoken": "^1.0.15",
+ "traverse": "^0.6.7",
+ "ua-parser-js": "^1.0.36",
+ "undici": "^7.10.0",
+ "winston": "^3.11.0",
+ "winston-daily-rotate-file": "^5.0.0",
+ "youtube-transcript": "^1.2.1",
+ "zod": "^3.22.4"
},
"devDependencies": {
- "nodemon": "^2.0.20",
- "path": "^0.12.7"
+ "jest": "^30.2.0",
+ "mongodb-memory-server": "^10.1.4",
+ "nodemon": "^3.0.3",
+ "supertest": "^7.1.0"
}
}
diff --git a/api/server/cleanup.js b/api/server/cleanup.js
new file mode 100644
index 0000000000..8e19c853ea
--- /dev/null
+++ b/api/server/cleanup.js
@@ -0,0 +1,429 @@
+const { logger } = require('@librechat/data-schemas');
+
+/** 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 {
+ // Ignore errors
+ }
+ })
+ : null;
+
+const graphPropsToClean = [
+ 'handlerRegistry',
+ 'runId',
+ 'tools',
+ 'signal',
+ 'config',
+ 'agentContexts',
+ 'messages',
+ 'contentData',
+ 'stepKeyIds',
+ 'contentIndexMap',
+ 'toolCallStepIds',
+ 'messageIdsByStepKey',
+ 'messageStepHasToolCalls',
+ 'prelimMessageIdsByStepKey',
+ 'startIndex',
+ 'defaultAgentId',
+ 'dispatchReasoningDelta',
+ 'compileOptions',
+ 'invokedToolIds',
+ 'overrideModel',
+];
+
+const graphRunnablePropsToClean = [
+ 'lc_serializable',
+ 'lc_kwargs',
+ 'lc_runnable',
+ 'name',
+ 'lc_namespace',
+ 'lg_is_pregel',
+ 'nodes',
+ 'channels',
+ 'inputChannels',
+ 'outputChannels',
+ 'autoValidate',
+ 'streamMode',
+ 'streamChannels',
+ 'interruptAfter',
+ 'interruptBefore',
+ 'stepTimeout',
+ 'debug',
+ 'checkpointer',
+ 'retryPolicy',
+ 'config',
+ 'store',
+ 'triggerToNodes',
+ 'cache',
+ 'description',
+ 'metaRegistry',
+];
+
+/**
+ * Cleans up the client object by removing potential circular 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.parentMessageId) {
+ client.parentMessageId = 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.isClaudeLatest !== undefined) {
+ client.isClaudeLatest = null;
+ }
+ if (client.useMessages !== undefined) {
+ client.useMessages = 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.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.processMemory) {
+ client.processMemory = null;
+ }
+
+ if (client.run) {
+ if (client.run.Graph) {
+ client.run.Graph.resetValues();
+
+ graphPropsToClean.forEach((prop) => {
+ if (client.run.Graph[prop] !== undefined) {
+ client.run.Graph[prop] = null;
+ }
+ });
+
+ client.run.Graph = null;
+ }
+
+ if (client.run.graphRunnable) {
+ graphRunnablePropsToClean.forEach((prop) => {
+ if (client.run.graphRunnable[prop] !== undefined) {
+ client.run.graphRunnable[prop] = null;
+ }
+ });
+
+ if (client.run.graphRunnable.builder) {
+ if (client.run.graphRunnable.builder.nodes !== undefined) {
+ client.run.graphRunnable.builder.nodes = null;
+ }
+ client.run.graphRunnable.builder = null;
+ }
+
+ client.run.graphRunnable = null;
+ }
+
+ const runPropsToClean = [
+ 'handlerRegistry',
+ 'id',
+ 'indexTokenCountMap',
+ 'returnContent',
+ 'tokenCounter',
+ ];
+
+ runPropsToClean.forEach((prop) => {
+ if (client.run[prop] !== undefined) {
+ client.run[prop] = 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.agentIdMap) {
+ client.agentIdMap = 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 {
+ // Ignore errors during disposal
+ } finally {
+ logger.debug('[disposeClient] Client disposed');
+ }
+}
+
+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,
+};
diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js
new file mode 100644
index 0000000000..dfef2bbfa1
--- /dev/null
+++ b/api/server/controllers/AuthController.js
@@ -0,0 +1,202 @@
+const cookies = require('cookie');
+const jwt = require('jsonwebtoken');
+const openIdClient = require('openid-client');
+const { logger } = require('@librechat/data-schemas');
+const { isEnabled, findOpenIDUser } = require('@librechat/api');
+const {
+ requestPasswordReset,
+ setOpenIDAuthTokens,
+ resetPassword,
+ setAuthTokens,
+ registerUser,
+} = require('~/server/services/AuthService');
+const { findUser, getUserById, deleteAllUserSessions, findSession } = require('~/models');
+const { getGraphApiToken } = require('~/server/services/GraphTokenService');
+const { getOAuthReconnectionManager } = require('~/config');
+const { getOpenIdConfig } = require('~/strategies');
+
+const registrationController = async (req, res) => {
+ try {
+ const response = await registerUser(req.body);
+ const { status, message } = response;
+ res.status(status).send({ message });
+ } catch (err) {
+ logger.error('[registrationController]', err);
+ return res.status(500).json({ message: err.message });
+ }
+};
+
+const resetPasswordRequestController = async (req, res) => {
+ try {
+ const resetService = await requestPasswordReset(req);
+ if (resetService instanceof Error) {
+ return res.status(400).json(resetService);
+ } else {
+ return res.status(200).json(resetService);
+ }
+ } catch (e) {
+ logger.error('[resetPasswordRequestController]', e);
+ return res.status(400).json({ message: e.message });
+ }
+};
+
+const resetPasswordController = async (req, res) => {
+ try {
+ const resetPasswordService = await resetPassword(
+ req.body.userId,
+ req.body.token,
+ req.body.password,
+ );
+ if (resetPasswordService instanceof Error) {
+ return res.status(400).json(resetPasswordService);
+ } else {
+ await deleteAllUserSessions({ userId: req.body.userId });
+ return res.status(200).json(resetPasswordService);
+ }
+ } catch (e) {
+ logger.error('[resetPasswordController]', e);
+ return res.status(400).json({ message: e.message });
+ }
+};
+
+const refreshController = async (req, res) => {
+ const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null;
+ const token_provider = req.headers.cookie
+ ? cookies.parse(req.headers.cookie).token_provider
+ : null;
+ if (!refreshToken) {
+ return res.status(200).send('Refresh token not provided');
+ }
+ if (token_provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS) === true) {
+ try {
+ const openIdConfig = getOpenIdConfig();
+ const tokenset = await openIdClient.refreshTokenGrant(openIdConfig, refreshToken);
+ const claims = tokenset.claims();
+ const { user, error } = await findOpenIDUser({
+ findUser,
+ email: claims.email,
+ openidId: claims.sub,
+ idOnTheSource: claims.oid,
+ strategyName: 'refreshController',
+ });
+ if (error || !user) {
+ return res.status(401).redirect('/login');
+ }
+ const token = setOpenIDAuthTokens(tokenset, res, user._id.toString(), refreshToken);
+
+ user.federatedTokens = {
+ access_token: tokenset.access_token,
+ id_token: tokenset.id_token,
+ refresh_token: refreshToken,
+ expires_at: claims.exp,
+ };
+
+ return res.status(200).send({ token, user });
+ } catch (error) {
+ logger.error('[refreshController] OpenID token refresh error', error);
+ return res.status(403).send('Invalid OpenID refresh token');
+ }
+ }
+ try {
+ const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
+ const user = await getUserById(payload.id, '-password -__v -totpSecret -backupCodes');
+ if (!user) {
+ return res.status(401).redirect('/login');
+ }
+
+ const userId = payload.id;
+
+ if (process.env.NODE_ENV === 'CI') {
+ const token = await setAuthTokens(userId, res);
+ return res.status(200).send({ token, user });
+ }
+
+ /** Session with the hashed refresh token */
+ const session = await findSession(
+ {
+ userId: userId,
+ refreshToken: refreshToken,
+ },
+ { lean: false },
+ );
+
+ if (session && session.expiration > new Date()) {
+ const token = await setAuthTokens(userId, res, session);
+
+ // trigger OAuth MCP server reconnection asynchronously (best effort)
+ try {
+ void getOAuthReconnectionManager()
+ .reconnectServers(userId)
+ .catch((err) => {
+ logger.error('[refreshController] Error reconnecting OAuth MCP servers:', err);
+ });
+ } catch (err) {
+ logger.warn(`[refreshController] Cannot attempt OAuth MCP servers reconnection:`, err);
+ }
+
+ res.status(200).send({ token, user });
+ } else if (req?.query?.retry) {
+ // Retrying from a refresh token request that failed (401)
+ res.status(403).send('No session found');
+ } else if (payload.exp < Date.now() / 1000) {
+ res.status(403).redirect('/login');
+ } else {
+ res.status(401).send('Refresh token expired or not found for this user');
+ }
+ } catch (err) {
+ logger.error(`[refreshController] Invalid refresh token:`, err);
+ res.status(403).send('Invalid refresh token');
+ }
+};
+
+const graphTokenController = async (req, res) => {
+ try {
+ // Validate user is authenticated via Entra ID
+ if (!req.user.openidId || req.user.provider !== 'openid') {
+ return res.status(403).json({
+ message: 'Microsoft Graph access requires Entra ID authentication',
+ });
+ }
+
+ // Check if OpenID token reuse is active (required for on-behalf-of flow)
+ if (!isEnabled(process.env.OPENID_REUSE_TOKENS)) {
+ return res.status(403).json({
+ message: 'SharePoint integration requires OpenID token reuse to be enabled',
+ });
+ }
+
+ // Extract access token from Authorization header
+ const authHeader = req.headers.authorization;
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ return res.status(401).json({
+ message: 'Valid authorization token required',
+ });
+ }
+
+ // Get scopes from query parameters
+ const scopes = req.query.scopes;
+ if (!scopes) {
+ return res.status(400).json({
+ message: 'Graph API scopes are required as query parameter',
+ });
+ }
+
+ const accessToken = authHeader.substring(7); // Remove 'Bearer ' prefix
+ const tokenResponse = await getGraphApiToken(req.user, accessToken, scopes);
+
+ res.json(tokenResponse);
+ } catch (error) {
+ logger.error('[graphTokenController] Failed to obtain Graph API token:', error);
+ res.status(500).json({
+ message: 'Failed to obtain Microsoft Graph token',
+ });
+ }
+};
+
+module.exports = {
+ refreshController,
+ registrationController,
+ resetPasswordController,
+ resetPasswordRequestController,
+ graphTokenController,
+};
diff --git a/api/server/controllers/Balance.js b/api/server/controllers/Balance.js
new file mode 100644
index 0000000000..c892a73b0c
--- /dev/null
+++ b/api/server/controllers/Balance.js
@@ -0,0 +1,24 @@
+const { Balance } = require('~/db/models');
+
+async function balanceController(req, res) {
+ const balanceData = await Balance.findOne(
+ { user: req.user.id },
+ '-_id tokenCredits autoRefillEnabled refillIntervalValue refillIntervalUnit lastRefill refillAmount',
+ ).lean();
+
+ if (!balanceData) {
+ return res.status(404).json({ error: 'Balance not found' });
+ }
+
+ // If auto-refill is not enabled, remove auto-refill related fields from the response
+ if (!balanceData.autoRefillEnabled) {
+ delete balanceData.refillIntervalValue;
+ delete balanceData.refillIntervalUnit;
+ delete balanceData.lastRefill;
+ delete balanceData.refillAmount;
+ }
+
+ res.status(200).json(balanceData);
+}
+
+module.exports = balanceController;
diff --git a/api/server/controllers/EndpointController.js b/api/server/controllers/EndpointController.js
new file mode 100644
index 0000000000..322ff179ea
--- /dev/null
+++ b/api/server/controllers/EndpointController.js
@@ -0,0 +1,8 @@
+const { getEndpointsConfig } = require('~/server/services/Config');
+
+async function endpointController(req, res) {
+ const endpointsConfig = await getEndpointsConfig(req);
+ res.send(JSON.stringify(endpointsConfig));
+}
+
+module.exports = endpointController;
diff --git a/api/server/controllers/FavoritesController.js b/api/server/controllers/FavoritesController.js
new file mode 100644
index 0000000000..186dd810bf
--- /dev/null
+++ b/api/server/controllers/FavoritesController.js
@@ -0,0 +1,99 @@
+const { updateUser, getUserById } = require('~/models');
+
+const MAX_FAVORITES = 50;
+const MAX_STRING_LENGTH = 256;
+
+const updateFavoritesController = async (req, res) => {
+ try {
+ const { favorites } = req.body;
+ const userId = req.user.id;
+
+ if (!favorites) {
+ return res.status(400).json({ message: 'Favorites data is required' });
+ }
+
+ if (!Array.isArray(favorites)) {
+ return res.status(400).json({ message: 'Favorites must be an array' });
+ }
+
+ if (favorites.length > MAX_FAVORITES) {
+ return res.status(400).json({
+ code: 'MAX_FAVORITES_EXCEEDED',
+ message: `Maximum ${MAX_FAVORITES} favorites allowed`,
+ limit: MAX_FAVORITES,
+ });
+ }
+
+ for (const fav of favorites) {
+ const hasAgent = !!fav.agentId;
+ const hasModel = !!(fav.model && fav.endpoint);
+
+ if (fav.agentId && fav.agentId.length > MAX_STRING_LENGTH) {
+ return res
+ .status(400)
+ .json({ message: `agentId exceeds maximum length of ${MAX_STRING_LENGTH}` });
+ }
+ if (fav.model && fav.model.length > MAX_STRING_LENGTH) {
+ return res
+ .status(400)
+ .json({ message: `model exceeds maximum length of ${MAX_STRING_LENGTH}` });
+ }
+ if (fav.endpoint && fav.endpoint.length > MAX_STRING_LENGTH) {
+ return res
+ .status(400)
+ .json({ message: `endpoint exceeds maximum length of ${MAX_STRING_LENGTH}` });
+ }
+
+ if (!hasAgent && !hasModel) {
+ return res.status(400).json({
+ message: 'Each favorite must have either agentId or model+endpoint',
+ });
+ }
+
+ if (hasAgent && hasModel) {
+ return res.status(400).json({
+ message: 'Favorite cannot have both agentId and model/endpoint',
+ });
+ }
+ }
+
+ const user = await updateUser(userId, { favorites });
+
+ if (!user) {
+ return res.status(404).json({ message: 'User not found' });
+ }
+
+ res.status(200).json(user.favorites);
+ } catch (error) {
+ console.error('Error updating favorites:', error);
+ res.status(500).json({ message: 'Internal server error' });
+ }
+};
+
+const getFavoritesController = async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const user = await getUserById(userId, 'favorites');
+
+ if (!user) {
+ return res.status(404).json({ message: 'User not found' });
+ }
+
+ let favorites = user.favorites || [];
+
+ if (!Array.isArray(favorites)) {
+ favorites = [];
+ await updateUser(userId, { favorites: [] });
+ }
+
+ res.status(200).json(favorites);
+ } catch (error) {
+ console.error('Error fetching favorites:', error);
+ res.status(500).json({ message: 'Internal server error' });
+ }
+};
+
+module.exports = {
+ updateFavoritesController,
+ getFavoritesController,
+};
diff --git a/api/server/controllers/ModelController.js b/api/server/controllers/ModelController.js
new file mode 100644
index 0000000000..805d9eef27
--- /dev/null
+++ b/api/server/controllers/ModelController.js
@@ -0,0 +1,50 @@
+const { logger } = require('@librechat/data-schemas');
+const { CacheKeys } = require('librechat-data-provider');
+const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config');
+const { getLogStores } = require('~/cache');
+
+/**
+ * @param {ServerRequest} req
+ * @returns {Promise} The models config.
+ */
+const getModelsConfig = async (req) => {
+ const cache = getLogStores(CacheKeys.CONFIG_STORE);
+ let modelsConfig = await cache.get(CacheKeys.MODELS_CONFIG);
+ if (!modelsConfig) {
+ modelsConfig = await loadModels(req);
+ }
+
+ return modelsConfig;
+};
+
+/**
+ * Loads the models from the config.
+ * @param {ServerRequest} req - The Express request object.
+ * @returns {Promise} The models config.
+ */
+async function loadModels(req) {
+ const cache = getLogStores(CacheKeys.CONFIG_STORE);
+ const cachedModelsConfig = await cache.get(CacheKeys.MODELS_CONFIG);
+ if (cachedModelsConfig) {
+ return cachedModelsConfig;
+ }
+ const defaultModelsConfig = await loadDefaultModels(req);
+ const customModelsConfig = await loadConfigModels(req);
+
+ const modelConfig = { ...defaultModelsConfig, ...customModelsConfig };
+
+ await cache.set(CacheKeys.MODELS_CONFIG, modelConfig);
+ return modelConfig;
+}
+
+async function modelController(req, res) {
+ try {
+ const modelConfig = await loadModels(req);
+ res.send(modelConfig);
+ } catch (error) {
+ logger.error('Error fetching models:', error);
+ res.status(500).send({ error: error.message });
+ }
+}
+
+module.exports = { modelController, loadModels, getModelsConfig };
diff --git a/api/server/controllers/PermissionsController.js b/api/server/controllers/PermissionsController.js
new file mode 100644
index 0000000000..e22e9532c9
--- /dev/null
+++ b/api/server/controllers/PermissionsController.js
@@ -0,0 +1,534 @@
+/**
+ * @import { TUpdateResourcePermissionsRequest, TUpdateResourcePermissionsResponse } from 'librechat-data-provider'
+ */
+
+const mongoose = require('mongoose');
+const { logger } = require('@librechat/data-schemas');
+const { ResourceType, PrincipalType, PermissionBits } = require('librechat-data-provider');
+const {
+ bulkUpdateResourcePermissions,
+ ensureGroupPrincipalExists,
+ getEffectivePermissions,
+ ensurePrincipalExists,
+ getAvailableRoles,
+ findAccessibleResources,
+ getResourcePermissionsMap,
+} = require('~/server/services/PermissionService');
+const { AclEntry } = require('~/db/models');
+const {
+ searchPrincipals: searchLocalPrincipals,
+ sortPrincipalsByRelevance,
+ calculateRelevanceScore,
+} = require('~/models');
+const {
+ entraIdPrincipalFeatureEnabled,
+ searchEntraIdPrincipals,
+} = require('~/server/services/GraphApiService');
+
+/**
+ * Generic controller for resource permission endpoints
+ * Delegates validation and logic to PermissionService
+ */
+
+/**
+ * Validates that the resourceType is one of the supported enum values
+ * @param {string} resourceType - The resource type to validate
+ * @throws {Error} If resourceType is not valid
+ */
+const validateResourceType = (resourceType) => {
+ const validTypes = Object.values(ResourceType);
+ if (!validTypes.includes(resourceType)) {
+ throw new Error(`Invalid resourceType: ${resourceType}. Valid types: ${validTypes.join(', ')}`);
+ }
+};
+
+/**
+ * Bulk update permissions for a resource (grant, update, remove)
+ * @route PUT /api/{resourceType}/{resourceId}/permissions
+ * @param {Object} req - Express request object
+ * @param {Object} req.params - Route parameters
+ * @param {string} req.params.resourceType - Resource type (e.g., 'agent')
+ * @param {string} req.params.resourceId - Resource ID
+ * @param {TUpdateResourcePermissionsRequest} req.body - Request body
+ * @param {Object} res - Express response object
+ * @returns {Promise} Updated permissions response
+ */
+const updateResourcePermissions = async (req, res) => {
+ try {
+ const { resourceType, resourceId } = req.params;
+ validateResourceType(resourceType);
+
+ /** @type {TUpdateResourcePermissionsRequest} */
+ const { updated, removed, public: isPublic, publicAccessRoleId } = req.body;
+ const { id: userId } = req.user;
+
+ // Prepare principals for the service call
+ const updatedPrincipals = [];
+ const revokedPrincipals = [];
+
+ // Add updated principals
+ if (updated && Array.isArray(updated)) {
+ updatedPrincipals.push(...updated);
+ }
+
+ // Add public permission if enabled
+ if (isPublic && publicAccessRoleId) {
+ updatedPrincipals.push({
+ type: PrincipalType.PUBLIC,
+ id: null,
+ accessRoleId: publicAccessRoleId,
+ });
+ }
+
+ // Prepare authentication context for enhanced group member fetching
+ const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
+ const authHeader = req.headers.authorization;
+ const accessToken =
+ authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
+ const authContext =
+ useEntraId && accessToken
+ ? {
+ accessToken,
+ sub: req.user.openidId,
+ }
+ : null;
+
+ // Ensure updated principals exist in the database before processing permissions
+ const validatedPrincipals = [];
+ for (const principal of updatedPrincipals) {
+ try {
+ let principalId;
+
+ if (principal.type === PrincipalType.PUBLIC) {
+ principalId = null; // Public principals don't need database records
+ } else if (principal.type === PrincipalType.ROLE) {
+ principalId = principal.id; // Role principals use role name as ID
+ } else if (principal.type === PrincipalType.USER) {
+ principalId = await ensurePrincipalExists(principal);
+ } else if (principal.type === PrincipalType.GROUP) {
+ // Pass authContext to enable member fetching for Entra ID groups when available
+ principalId = await ensureGroupPrincipalExists(principal, authContext);
+ } else {
+ logger.error(`Unsupported principal type: ${principal.type}`);
+ continue; // Skip invalid principal types
+ }
+
+ // Update the principal with the validated ID for ACL operations
+ validatedPrincipals.push({
+ ...principal,
+ id: principalId,
+ });
+ } catch (error) {
+ logger.error('Error ensuring principal exists:', {
+ principal: {
+ type: principal.type,
+ id: principal.id,
+ name: principal.name,
+ source: principal.source,
+ },
+ error: error.message,
+ });
+ // Continue with other principals instead of failing the entire operation
+ continue;
+ }
+ }
+
+ // Add removed principals
+ if (removed && Array.isArray(removed)) {
+ revokedPrincipals.push(...removed);
+ }
+
+ // If public is disabled, add public to revoked list
+ if (!isPublic) {
+ revokedPrincipals.push({
+ type: PrincipalType.PUBLIC,
+ id: null,
+ });
+ }
+
+ const results = await bulkUpdateResourcePermissions({
+ resourceType,
+ resourceId,
+ updatedPrincipals: validatedPrincipals,
+ revokedPrincipals,
+ grantedBy: userId,
+ });
+
+ /** @type {TUpdateResourcePermissionsResponse} */
+ const response = {
+ message: 'Permissions updated successfully',
+ results: {
+ principals: results.granted,
+ public: isPublic || false,
+ publicAccessRoleId: isPublic ? publicAccessRoleId : undefined,
+ },
+ };
+
+ res.status(200).json(response);
+ } catch (error) {
+ logger.error('Error updating resource permissions:', error);
+ res.status(400).json({
+ error: 'Failed to update permissions',
+ details: error.message,
+ });
+ }
+};
+
+/**
+ * Get principals with their permission roles for a resource (UI-friendly format)
+ * Uses efficient aggregation pipeline to join User/Group data in single query
+ * @route GET /api/permissions/{resourceType}/{resourceId}
+ */
+const getResourcePermissions = async (req, res) => {
+ try {
+ const { resourceType, resourceId } = req.params;
+ validateResourceType(resourceType);
+
+ // Use aggregation pipeline for efficient single-query data retrieval
+ const results = await AclEntry.aggregate([
+ // Match ACL entries for this resource
+ {
+ $match: {
+ resourceType,
+ resourceId: mongoose.Types.ObjectId.isValid(resourceId)
+ ? mongoose.Types.ObjectId.createFromHexString(resourceId)
+ : resourceId,
+ },
+ },
+ // Lookup AccessRole information
+ {
+ $lookup: {
+ from: 'accessroles',
+ localField: 'roleId',
+ foreignField: '_id',
+ as: 'role',
+ },
+ },
+ // Lookup User information (for user principals)
+ {
+ $lookup: {
+ from: 'users',
+ localField: 'principalId',
+ foreignField: '_id',
+ as: 'userInfo',
+ },
+ },
+ // Lookup Group information (for group principals)
+ {
+ $lookup: {
+ from: 'groups',
+ localField: 'principalId',
+ foreignField: '_id',
+ as: 'groupInfo',
+ },
+ },
+ // Project final structure
+ {
+ $project: {
+ principalType: 1,
+ principalId: 1,
+ accessRoleId: { $arrayElemAt: ['$role.accessRoleId', 0] },
+ userInfo: { $arrayElemAt: ['$userInfo', 0] },
+ groupInfo: { $arrayElemAt: ['$groupInfo', 0] },
+ },
+ },
+ ]);
+
+ const principals = [];
+ let publicPermission = null;
+
+ // Process aggregation results
+ for (const result of results) {
+ if (result.principalType === PrincipalType.PUBLIC) {
+ publicPermission = {
+ public: true,
+ publicAccessRoleId: result.accessRoleId,
+ };
+ } else if (result.principalType === PrincipalType.USER && result.userInfo) {
+ principals.push({
+ type: PrincipalType.USER,
+ id: result.userInfo._id.toString(),
+ name: result.userInfo.name || result.userInfo.username,
+ email: result.userInfo.email,
+ avatar: result.userInfo.avatar,
+ source: !result.userInfo._id ? 'entra' : 'local',
+ idOnTheSource: result.userInfo.idOnTheSource || result.userInfo._id.toString(),
+ accessRoleId: result.accessRoleId,
+ });
+ } else if (result.principalType === PrincipalType.GROUP && result.groupInfo) {
+ principals.push({
+ type: PrincipalType.GROUP,
+ id: result.groupInfo._id.toString(),
+ name: result.groupInfo.name,
+ email: result.groupInfo.email,
+ description: result.groupInfo.description,
+ avatar: result.groupInfo.avatar,
+ source: result.groupInfo.source || 'local',
+ idOnTheSource: result.groupInfo.idOnTheSource || result.groupInfo._id.toString(),
+ accessRoleId: result.accessRoleId,
+ });
+ } else if (result.principalType === PrincipalType.ROLE) {
+ principals.push({
+ type: PrincipalType.ROLE,
+ /** Role name as ID */
+ id: result.principalId,
+ /** Display the role name */
+ name: result.principalId,
+ description: `System role: ${result.principalId}`,
+ accessRoleId: result.accessRoleId,
+ });
+ }
+ }
+
+ // Return response in format expected by frontend
+ const response = {
+ resourceType,
+ resourceId,
+ principals,
+ public: publicPermission?.public || false,
+ ...(publicPermission?.publicAccessRoleId && {
+ publicAccessRoleId: publicPermission.publicAccessRoleId,
+ }),
+ };
+
+ res.status(200).json(response);
+ } catch (error) {
+ logger.error('Error getting resource permissions principals:', error);
+ res.status(500).json({
+ error: 'Failed to get permissions principals',
+ details: error.message,
+ });
+ }
+};
+
+/**
+ * Get available roles for a resource type
+ * @route GET /api/{resourceType}/roles
+ */
+const getResourceRoles = async (req, res) => {
+ try {
+ const { resourceType } = req.params;
+ validateResourceType(resourceType);
+
+ const roles = await getAvailableRoles({ resourceType });
+
+ res.status(200).json(
+ roles.map((role) => ({
+ accessRoleId: role.accessRoleId,
+ name: role.name,
+ description: role.description,
+ permBits: role.permBits,
+ })),
+ );
+ } catch (error) {
+ logger.error('Error getting resource roles:', error);
+ res.status(500).json({
+ error: 'Failed to get roles',
+ details: error.message,
+ });
+ }
+};
+
+/**
+ * Get user's effective permission bitmask for a resource
+ * @route GET /api/{resourceType}/{resourceId}/effective
+ */
+const getUserEffectivePermissions = async (req, res) => {
+ try {
+ const { resourceType, resourceId } = req.params;
+ validateResourceType(resourceType);
+
+ const { id: userId } = req.user;
+
+ const permissionBits = await getEffectivePermissions({
+ userId,
+ role: req.user.role,
+ resourceType,
+ resourceId,
+ });
+
+ res.status(200).json({
+ permissionBits,
+ });
+ } catch (error) {
+ logger.error('Error getting user effective permissions:', error);
+ res.status(500).json({
+ error: 'Failed to get effective permissions',
+ details: error.message,
+ });
+ }
+};
+
+/**
+ * Search for users and groups to grant permissions
+ * Supports hybrid local database + Entra ID search when configured
+ * @route GET /api/permissions/search-principals
+ */
+const searchPrincipals = async (req, res) => {
+ try {
+ const { q: query, limit = 20, types } = req.query;
+
+ if (!query || query.trim().length === 0) {
+ return res.status(400).json({
+ error: 'Query parameter "q" is required and must not be empty',
+ });
+ }
+
+ if (query.trim().length < 2) {
+ return res.status(400).json({
+ error: 'Query must be at least 2 characters long',
+ });
+ }
+
+ const searchLimit = Math.min(Math.max(1, parseInt(limit) || 10), 50);
+
+ let typeFilters = null;
+ if (types) {
+ const typesArray = Array.isArray(types) ? types : types.split(',');
+ const validTypes = typesArray.filter((t) =>
+ [PrincipalType.USER, PrincipalType.GROUP, PrincipalType.ROLE].includes(t),
+ );
+ typeFilters = validTypes.length > 0 ? validTypes : null;
+ }
+
+ const localResults = await searchLocalPrincipals(query.trim(), searchLimit, typeFilters);
+ let allPrincipals = [...localResults];
+
+ const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
+
+ if (useEntraId && localResults.length < searchLimit) {
+ try {
+ let graphType = 'all';
+ if (typeFilters && typeFilters.length === 1) {
+ const graphTypeMap = {
+ [PrincipalType.USER]: 'users',
+ [PrincipalType.GROUP]: 'groups',
+ };
+ const mappedType = graphTypeMap[typeFilters[0]];
+ if (mappedType) {
+ graphType = mappedType;
+ }
+ }
+
+ const authHeader = req.headers.authorization;
+ const accessToken =
+ authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
+
+ if (accessToken) {
+ const graphResults = await searchEntraIdPrincipals(
+ accessToken,
+ req.user.openidId,
+ query.trim(),
+ graphType,
+ searchLimit - localResults.length,
+ );
+
+ const localEmails = new Set(
+ localResults.map((p) => p.email?.toLowerCase()).filter(Boolean),
+ );
+ const localGroupSourceIds = new Set(
+ localResults.map((p) => p.idOnTheSource).filter(Boolean),
+ );
+
+ for (const principal of graphResults) {
+ const isDuplicateByEmail =
+ principal.email && localEmails.has(principal.email.toLowerCase());
+ const isDuplicateBySourceId =
+ principal.idOnTheSource && localGroupSourceIds.has(principal.idOnTheSource);
+
+ if (!isDuplicateByEmail && !isDuplicateBySourceId) {
+ allPrincipals.push(principal);
+ }
+ }
+ }
+ } catch (graphError) {
+ logger.warn('Graph API search failed, falling back to local results:', graphError.message);
+ }
+ }
+ const scoredResults = allPrincipals.map((item) => ({
+ ...item,
+ _searchScore: calculateRelevanceScore(item, query.trim()),
+ }));
+
+ const finalResults = sortPrincipalsByRelevance(scoredResults)
+ .slice(0, searchLimit)
+ .map((result) => {
+ const { _searchScore, ...resultWithoutScore } = result;
+ return resultWithoutScore;
+ });
+
+ res.status(200).json({
+ query: query.trim(),
+ limit: searchLimit,
+ types: typeFilters,
+ results: finalResults,
+ count: finalResults.length,
+ sources: {
+ local: finalResults.filter((r) => r.source === 'local').length,
+ entra: finalResults.filter((r) => r.source === 'entra').length,
+ },
+ });
+ } catch (error) {
+ logger.error('Error searching principals:', error);
+ res.status(500).json({
+ error: 'Failed to search principals',
+ details: error.message,
+ });
+ }
+};
+
+/**
+ * Get user's effective permissions for all accessible resources of a type
+ * @route GET /api/permissions/{resourceType}/effective/all
+ */
+const getAllEffectivePermissions = async (req, res) => {
+ try {
+ const { resourceType } = req.params;
+ validateResourceType(resourceType);
+
+ const { id: userId } = req.user;
+
+ // Find all resources the user has at least VIEW access to
+ const accessibleResourceIds = await findAccessibleResources({
+ userId,
+ role: req.user.role,
+ resourceType,
+ requiredPermissions: PermissionBits.VIEW,
+ });
+
+ if (accessibleResourceIds.length === 0) {
+ return res.status(200).json({});
+ }
+
+ // Get effective permissions for all accessible resources
+ const permissionsMap = await getResourcePermissionsMap({
+ userId,
+ role: req.user.role,
+ resourceType,
+ resourceIds: accessibleResourceIds,
+ });
+
+ // Convert Map to plain object for JSON response
+ const result = {};
+ for (const [resourceId, permBits] of permissionsMap) {
+ result[resourceId] = permBits;
+ }
+
+ res.status(200).json(result);
+ } catch (error) {
+ logger.error('Error getting all effective permissions:', error);
+ res.status(500).json({
+ error: 'Failed to get all effective permissions',
+ details: error.message,
+ });
+ }
+};
+
+module.exports = {
+ updateResourcePermissions,
+ getResourcePermissions,
+ getResourceRoles,
+ getUserEffectivePermissions,
+ getAllEffectivePermissions,
+ searchPrincipals,
+};
diff --git a/api/server/controllers/PluginController.js b/api/server/controllers/PluginController.js
new file mode 100644
index 0000000000..c5e074b8ff
--- /dev/null
+++ b/api/server/controllers/PluginController.js
@@ -0,0 +1,129 @@
+const { logger } = require('@librechat/data-schemas');
+const { CacheKeys } = require('librechat-data-provider');
+const { getToolkitKey, checkPluginAuth, filterUniquePlugins } = require('@librechat/api');
+const { getCachedTools, setCachedTools } = require('~/server/services/Config');
+const { availableTools, toolkits } = require('~/app/clients/tools');
+const { getAppConfig } = require('~/server/services/Config');
+const { getLogStores } = require('~/cache');
+
+const getAvailablePluginsController = async (req, res) => {
+ try {
+ const cache = getLogStores(CacheKeys.CONFIG_STORE);
+ const cachedPlugins = await cache.get(CacheKeys.PLUGINS);
+ if (cachedPlugins) {
+ res.status(200).json(cachedPlugins);
+ return;
+ }
+
+ const appConfig = await getAppConfig({ role: req.user?.role });
+ /** @type {{ filteredTools: string[], includedTools: string[] }} */
+ const { filteredTools = [], includedTools = [] } = appConfig;
+ /** @type {import('@librechat/api').LCManifestTool[]} */
+ const pluginManifest = availableTools;
+
+ const uniquePlugins = filterUniquePlugins(pluginManifest);
+ let authenticatedPlugins = [];
+ for (const plugin of uniquePlugins) {
+ authenticatedPlugins.push(
+ checkPluginAuth(plugin) ? { ...plugin, authenticated: true } : plugin,
+ );
+ }
+
+ let plugins = authenticatedPlugins;
+
+ if (includedTools.length > 0) {
+ plugins = plugins.filter((plugin) => includedTools.includes(plugin.pluginKey));
+ } else {
+ plugins = plugins.filter((plugin) => !filteredTools.includes(plugin.pluginKey));
+ }
+
+ await cache.set(CacheKeys.PLUGINS, plugins);
+ res.status(200).json(plugins);
+ } catch (error) {
+ res.status(500).json({ message: error.message });
+ }
+};
+
+/**
+ * Retrieves and returns a list of available tools, either from a cache or by reading a plugin manifest file.
+ *
+ * This function first attempts to retrieve the list of tools from a cache. If the tools are not found in the cache,
+ * it reads a plugin manifest file, filters for unique plugins, and determines if each plugin is authenticated.
+ * Only plugins that are marked as available in the application's local state are included in the final list.
+ * The resulting list of tools is then cached and sent to the client.
+ *
+ * @param {object} req - The request object, containing information about the HTTP request.
+ * @param {object} res - The response object, used to send back the desired HTTP response.
+ * @returns {Promise} A promise that resolves when the function has completed.
+ */
+const getAvailableTools = async (req, res) => {
+ try {
+ const userId = req.user?.id;
+ if (!userId) {
+ logger.warn('[getAvailableTools] User ID not found in request');
+ return res.status(401).json({ message: 'Unauthorized' });
+ }
+ const cache = getLogStores(CacheKeys.CONFIG_STORE);
+ const cachedToolsArray = await cache.get(CacheKeys.TOOLS);
+
+ const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role }));
+
+ // Return early if we have cached tools
+ if (cachedToolsArray != null) {
+ res.status(200).json(cachedToolsArray);
+ return;
+ }
+
+ /** @type {Record | null} Get tool definitions to filter which tools are actually available */
+ let toolDefinitions = await getCachedTools();
+
+ if (toolDefinitions == null && appConfig?.availableTools != null) {
+ logger.warn('[getAvailableTools] Tool cache was empty, re-initializing from app config');
+ await setCachedTools(appConfig.availableTools);
+ toolDefinitions = appConfig.availableTools;
+ }
+
+ /** @type {import('@librechat/api').LCManifestTool[]} */
+ let pluginManifest = availableTools;
+
+ /** @type {TPlugin[]} Deduplicate and authenticate plugins */
+ const uniquePlugins = filterUniquePlugins(pluginManifest);
+ const authenticatedPlugins = uniquePlugins.map((plugin) => {
+ if (checkPluginAuth(plugin)) {
+ return { ...plugin, authenticated: true };
+ } else {
+ return plugin;
+ }
+ });
+
+ /** Filter plugins based on availability */
+ const toolsOutput = [];
+ for (const plugin of authenticatedPlugins) {
+ const isToolDefined = toolDefinitions?.[plugin.pluginKey] !== undefined;
+ const isToolkit =
+ plugin.toolkit === true &&
+ Object.keys(toolDefinitions ?? {}).some(
+ (key) => getToolkitKey({ toolkits, toolName: key }) === plugin.pluginKey,
+ );
+
+ if (!isToolDefined && !isToolkit) {
+ continue;
+ }
+
+ toolsOutput.push(plugin);
+ }
+
+ const finalTools = filterUniquePlugins(toolsOutput);
+ await cache.set(CacheKeys.TOOLS, finalTools);
+
+ res.status(200).json(finalTools);
+ } catch (error) {
+ logger.error('[getAvailableTools]', error);
+ res.status(500).json({ message: error.message });
+ }
+};
+
+module.exports = {
+ getAvailableTools,
+ getAvailablePluginsController,
+};
diff --git a/api/server/controllers/PluginController.spec.js b/api/server/controllers/PluginController.spec.js
new file mode 100644
index 0000000000..d7d3f83a8b
--- /dev/null
+++ b/api/server/controllers/PluginController.spec.js
@@ -0,0 +1,493 @@
+const { getCachedTools, getAppConfig } = require('~/server/services/Config');
+const { getLogStores } = require('~/cache');
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: {
+ debug: jest.fn(),
+ error: jest.fn(),
+ warn: jest.fn(),
+ },
+}));
+
+jest.mock('~/server/services/Config', () => ({
+ getCachedTools: jest.fn(),
+ getAppConfig: jest.fn().mockResolvedValue({
+ filteredTools: [],
+ includedTools: [],
+ }),
+ setCachedTools: jest.fn(),
+}));
+
+// loadAndFormatTools mock removed - no longer used in PluginController
+// getMCPManager mock removed - no longer used in PluginController
+
+jest.mock('~/app/clients/tools', () => ({
+ availableTools: [],
+ toolkits: [],
+}));
+
+jest.mock('~/cache', () => ({
+ getLogStores: jest.fn(),
+}));
+
+const { getAvailableTools, getAvailablePluginsController } = require('./PluginController');
+
+describe('PluginController', () => {
+ let mockReq, mockRes, mockCache;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockReq = {
+ user: { id: 'test-user-id' },
+ config: {
+ filteredTools: [],
+ includedTools: [],
+ },
+ };
+ mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
+ mockCache = { get: jest.fn(), set: jest.fn() };
+ getLogStores.mockReturnValue(mockCache);
+
+ // Clear availableTools and toolkits arrays before each test
+ require('~/app/clients/tools').availableTools.length = 0;
+ require('~/app/clients/tools').toolkits.length = 0;
+
+ // Reset getCachedTools mock to ensure clean state
+ getCachedTools.mockReset();
+
+ // Reset getAppConfig mock to ensure clean state with default values
+ getAppConfig.mockReset();
+ getAppConfig.mockResolvedValue({
+ filteredTools: [],
+ includedTools: [],
+ });
+ });
+
+ describe('getAvailablePluginsController', () => {
+ it('should use filterUniquePlugins to remove duplicate plugins', async () => {
+ // Add plugins with duplicates to availableTools
+ const mockPlugins = [
+ { name: 'Plugin1', pluginKey: 'key1', description: 'First' },
+ { name: 'Plugin1', pluginKey: 'key1', description: 'First duplicate' },
+ { name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
+ ];
+
+ require('~/app/clients/tools').availableTools.push(...mockPlugins);
+
+ mockCache.get.mockResolvedValue(null);
+
+ // Configure getAppConfig to return the expected config
+ getAppConfig.mockResolvedValueOnce({
+ filteredTools: [],
+ includedTools: [],
+ });
+
+ await getAvailablePluginsController(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ const responseData = mockRes.json.mock.calls[0][0];
+ // The real filterUniquePlugins should have removed the duplicate
+ expect(responseData).toHaveLength(2);
+ expect(responseData[0].pluginKey).toBe('key1');
+ expect(responseData[1].pluginKey).toBe('key2');
+ });
+
+ it('should use checkPluginAuth to verify plugin authentication', async () => {
+ // checkPluginAuth returns false for plugins without authConfig
+ // so authenticated property won't be added
+ const mockPlugin = { name: 'Plugin1', pluginKey: 'key1', description: 'First' };
+
+ require('~/app/clients/tools').availableTools.push(mockPlugin);
+ mockCache.get.mockResolvedValue(null);
+
+ // Configure getAppConfig to return the expected config
+ getAppConfig.mockResolvedValueOnce({
+ filteredTools: [],
+ includedTools: [],
+ });
+
+ await getAvailablePluginsController(mockReq, mockRes);
+
+ const responseData = mockRes.json.mock.calls[0][0];
+ // The real checkPluginAuth returns false for plugins without authConfig, so authenticated property is not added
+ expect(responseData[0].authenticated).toBeUndefined();
+ });
+
+ it('should return cached plugins when available', async () => {
+ const cachedPlugins = [
+ { name: 'CachedPlugin', pluginKey: 'cached', description: 'Cached plugin' },
+ ];
+
+ mockCache.get.mockResolvedValue(cachedPlugins);
+
+ await getAvailablePluginsController(mockReq, mockRes);
+
+ // When cache is hit, we return immediately without processing
+ expect(mockRes.json).toHaveBeenCalledWith(cachedPlugins);
+ });
+
+ it('should filter plugins based on includedTools', async () => {
+ const mockPlugins = [
+ { name: 'Plugin1', pluginKey: 'key1', description: 'First' },
+ { name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
+ ];
+
+ require('~/app/clients/tools').availableTools.push(...mockPlugins);
+ mockCache.get.mockResolvedValue(null);
+
+ // Configure getAppConfig to return config with includedTools
+ getAppConfig.mockResolvedValueOnce({
+ filteredTools: [],
+ includedTools: ['key1'],
+ });
+
+ await getAvailablePluginsController(mockReq, mockRes);
+
+ const responseData = mockRes.json.mock.calls[0][0];
+ expect(responseData).toHaveLength(1);
+ expect(responseData[0].pluginKey).toBe('key1');
+ });
+ });
+
+ describe('getAvailableTools', () => {
+ it('should use filterUniquePlugins to deduplicate combined tools', async () => {
+ const mockUserTools = {
+ 'user-tool': {
+ type: 'function',
+ function: {
+ name: 'user-tool',
+ description: 'User tool',
+ parameters: { type: 'object', properties: {} },
+ },
+ },
+ };
+
+ const mockCachedPlugins = [
+ { name: 'user-tool', pluginKey: 'user-tool', description: 'Duplicate user tool' },
+ { name: 'ManifestTool', pluginKey: 'manifest-tool', description: 'Manifest tool' },
+ ];
+
+ mockCache.get.mockResolvedValue(mockCachedPlugins);
+ getCachedTools.mockResolvedValueOnce(mockUserTools);
+ mockReq.config = {
+ mcpConfig: null,
+ paths: { structuredTools: '/mock/path' },
+ };
+
+ await getAvailableTools(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ const responseData = mockRes.json.mock.calls[0][0];
+ expect(Array.isArray(responseData)).toBe(true);
+ // The real filterUniquePlugins should have deduplicated tools with same pluginKey
+ const userToolCount = responseData.filter((tool) => tool.pluginKey === 'user-tool').length;
+ expect(userToolCount).toBe(1);
+ });
+
+ it('should use checkPluginAuth to verify authentication status', async () => {
+ // Add a plugin to availableTools that will be checked
+ const mockPlugin = {
+ name: 'Tool1',
+ pluginKey: 'tool1',
+ description: 'Tool 1',
+ // No authConfig means checkPluginAuth returns false
+ };
+
+ require('~/app/clients/tools').availableTools.push(mockPlugin);
+
+ mockCache.get.mockResolvedValue(null);
+ // getCachedTools returns the tool definitions
+ getCachedTools.mockResolvedValueOnce({
+ tool1: {
+ type: 'function',
+ function: {
+ name: 'tool1',
+ description: 'Tool 1',
+ parameters: {},
+ },
+ },
+ });
+ mockReq.config = {
+ mcpConfig: null,
+ paths: { structuredTools: '/mock/path' },
+ };
+
+ await getAvailableTools(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ const responseData = mockRes.json.mock.calls[0][0];
+ expect(Array.isArray(responseData)).toBe(true);
+ const tool = responseData.find((t) => t.pluginKey === 'tool1');
+ expect(tool).toBeDefined();
+ // The real checkPluginAuth returns false for plugins without authConfig, so authenticated property is not added
+ expect(tool.authenticated).toBeUndefined();
+ });
+
+ it('should use getToolkitKey for toolkit validation', async () => {
+ const mockToolkit = {
+ name: 'Toolkit1',
+ pluginKey: 'toolkit1',
+ description: 'Toolkit 1',
+ toolkit: true,
+ };
+
+ require('~/app/clients/tools').availableTools.push(mockToolkit);
+
+ // Mock toolkits to have a mapping
+ require('~/app/clients/tools').toolkits.push({
+ name: 'Toolkit1',
+ pluginKey: 'toolkit1',
+ tools: ['toolkit1_function'],
+ });
+
+ mockCache.get.mockResolvedValue(null);
+ // getCachedTools returns the tool definitions
+ getCachedTools.mockResolvedValueOnce({
+ toolkit1_function: {
+ type: 'function',
+ function: {
+ name: 'toolkit1_function',
+ description: 'Toolkit function',
+ parameters: {},
+ },
+ },
+ });
+ mockReq.config = {
+ mcpConfig: null,
+ paths: { structuredTools: '/mock/path' },
+ };
+
+ await getAvailableTools(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ const responseData = mockRes.json.mock.calls[0][0];
+ expect(Array.isArray(responseData)).toBe(true);
+ const toolkit = responseData.find((t) => t.pluginKey === 'toolkit1');
+ expect(toolkit).toBeDefined();
+ });
+ });
+
+ describe('helper function integration', () => {
+ it('should handle error cases gracefully', async () => {
+ mockCache.get.mockRejectedValue(new Error('Cache error'));
+
+ await getAvailableTools(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(500);
+ expect(mockRes.json).toHaveBeenCalledWith({ message: 'Cache error' });
+ });
+ });
+
+ describe('edge cases with undefined/null values', () => {
+ it('should handle undefined cache gracefully', async () => {
+ getLogStores.mockReturnValue(undefined);
+
+ await getAvailableTools(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(500);
+ });
+
+ it('should handle null cachedTools and cachedUserTools', async () => {
+ mockCache.get.mockResolvedValue(null);
+ // getCachedTools returns empty object instead of null
+ getCachedTools.mockResolvedValueOnce({});
+ mockReq.config = {
+ mcpConfig: null,
+ paths: { structuredTools: '/mock/path' },
+ };
+
+ await getAvailableTools(mockReq, mockRes);
+
+ // Should handle null values gracefully
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ expect(mockRes.json).toHaveBeenCalledWith([]);
+ });
+
+ it('should handle when getCachedTools returns undefined', async () => {
+ mockCache.get.mockResolvedValue(null);
+ mockReq.config = {
+ mcpConfig: null,
+ paths: { structuredTools: '/mock/path' },
+ };
+
+ // Mock getCachedTools to return undefined
+ getCachedTools.mockReset();
+ getCachedTools.mockResolvedValueOnce(undefined);
+
+ await getAvailableTools(mockReq, mockRes);
+
+ // Should handle undefined values gracefully
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ expect(mockRes.json).toHaveBeenCalledWith([]);
+ });
+
+ it('should handle empty toolDefinitions object', async () => {
+ mockCache.get.mockResolvedValue(null);
+ // Reset getCachedTools to ensure clean state
+ getCachedTools.mockReset();
+ getCachedTools.mockResolvedValue({});
+ mockReq.config = {}; // No mcpConfig at all
+
+ // Ensure no plugins are available
+ require('~/app/clients/tools').availableTools.length = 0;
+
+ await getAvailableTools(mockReq, mockRes);
+
+ // With empty tool definitions, no tools should be in the final output
+ expect(mockRes.json).toHaveBeenCalledWith([]);
+ });
+
+ it('should handle undefined filteredTools and includedTools', async () => {
+ mockReq.config = {};
+ mockCache.get.mockResolvedValue(null);
+
+ // Configure getAppConfig to return config with undefined properties
+ // The controller will use default values [] for filteredTools and includedTools
+ getAppConfig.mockResolvedValueOnce({});
+
+ await getAvailablePluginsController(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ expect(mockRes.json).toHaveBeenCalledWith([]);
+ });
+
+ it('should handle toolkit with undefined toolDefinitions keys', async () => {
+ const mockToolkit = {
+ name: 'Toolkit1',
+ pluginKey: 'toolkit1',
+ description: 'Toolkit 1',
+ toolkit: true,
+ };
+
+ // No need to mock app.locals anymore as it's not used
+
+ // Add the toolkit to availableTools
+ require('~/app/clients/tools').availableTools.push(mockToolkit);
+
+ mockCache.get.mockResolvedValue(null);
+ // getCachedTools returns empty object to avoid null reference error
+ getCachedTools.mockResolvedValueOnce({});
+ mockReq.config = {
+ mcpConfig: null,
+ paths: { structuredTools: '/mock/path' },
+ };
+
+ await getAvailableTools(mockReq, mockRes);
+
+ // Should handle null toolDefinitions gracefully
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ });
+
+ it('should handle undefined toolDefinitions when checking isToolDefined (traversaal_search bug)', async () => {
+ // This test reproduces the bug where toolDefinitions is undefined
+ // and accessing toolDefinitions[plugin.pluginKey] causes a TypeError
+ const mockPlugin = {
+ name: 'Traversaal Search',
+ pluginKey: 'traversaal_search',
+ description: 'Search plugin',
+ };
+
+ // Add the plugin to availableTools
+ require('~/app/clients/tools').availableTools.push(mockPlugin);
+
+ mockCache.get.mockResolvedValue(null);
+
+ mockReq.config = {
+ mcpConfig: null,
+ paths: { structuredTools: '/mock/path' },
+ };
+
+ // CRITICAL: getCachedTools returns undefined
+ // This is what causes the bug when trying to access toolDefinitions[plugin.pluginKey]
+ getCachedTools.mockResolvedValueOnce(undefined);
+
+ // This should not throw an error with the optional chaining fix
+ await getAvailableTools(mockReq, mockRes);
+
+ // Should handle undefined toolDefinitions gracefully and return empty array
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ expect(mockRes.json).toHaveBeenCalledWith([]);
+ });
+
+ it('should re-initialize tools from appConfig when cache returns null', async () => {
+ // Setup: Initial state with tools in appConfig
+ const mockAppTools = {
+ tool1: {
+ type: 'function',
+ function: {
+ name: 'tool1',
+ description: 'Tool 1',
+ parameters: {},
+ },
+ },
+ tool2: {
+ type: 'function',
+ function: {
+ name: 'tool2',
+ description: 'Tool 2',
+ parameters: {},
+ },
+ },
+ };
+
+ // Add matching plugins to availableTools
+ require('~/app/clients/tools').availableTools.push(
+ { name: 'Tool 1', pluginKey: 'tool1', description: 'Tool 1' },
+ { name: 'Tool 2', pluginKey: 'tool2', description: 'Tool 2' },
+ );
+
+ // Simulate cache cleared state (returns null)
+ mockCache.get.mockResolvedValue(null);
+ getCachedTools.mockResolvedValueOnce(null); // Global tools (cache cleared)
+
+ mockReq.config = {
+ filteredTools: [],
+ includedTools: [],
+ availableTools: mockAppTools,
+ };
+
+ // Mock setCachedTools to verify it's called to re-initialize
+ const { setCachedTools } = require('~/server/services/Config');
+
+ await getAvailableTools(mockReq, mockRes);
+
+ // Should have re-initialized the cache with tools from appConfig
+ expect(setCachedTools).toHaveBeenCalledWith(mockAppTools);
+
+ // Should still return tools successfully
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ const responseData = mockRes.json.mock.calls[0][0];
+ expect(responseData).toHaveLength(2);
+ expect(responseData.find((t) => t.pluginKey === 'tool1')).toBeDefined();
+ expect(responseData.find((t) => t.pluginKey === 'tool2')).toBeDefined();
+ });
+
+ it('should handle cache clear without appConfig.availableTools gracefully', async () => {
+ // Setup: appConfig without availableTools
+ getAppConfig.mockResolvedValue({
+ filteredTools: [],
+ includedTools: [],
+ // No availableTools property
+ });
+
+ // Clear availableTools array
+ require('~/app/clients/tools').availableTools.length = 0;
+
+ // Cache returns null (cleared state)
+ mockCache.get.mockResolvedValue(null);
+ getCachedTools.mockResolvedValueOnce(null); // Global tools (cache cleared)
+
+ mockReq.config = {
+ filteredTools: [],
+ includedTools: [],
+ // No availableTools
+ };
+
+ await getAvailableTools(mockReq, mockRes);
+
+ // Should handle gracefully without crashing
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ expect(mockRes.json).toHaveBeenCalledWith([]);
+ });
+ });
+});
diff --git a/api/server/controllers/TwoFactorController.js b/api/server/controllers/TwoFactorController.js
new file mode 100644
index 0000000000..fde5965261
--- /dev/null
+++ b/api/server/controllers/TwoFactorController.js
@@ -0,0 +1,163 @@
+const { encryptV3, logger } = require('@librechat/data-schemas');
+const {
+ generateBackupCodes,
+ generateTOTPSecret,
+ verifyBackupCode,
+ getTOTPSecret,
+ verifyTOTP,
+} = require('~/server/services/twoFactorService');
+const { getUserById, updateUser } = require('~/models');
+
+const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, '');
+
+/**
+ * Enable 2FA for the user by generating a new TOTP secret and backup codes.
+ * The secret is encrypted and stored, and 2FA is marked as disabled until confirmed.
+ */
+const enable2FA = async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const secret = generateTOTPSecret();
+ const { plainCodes, codeObjects } = await generateBackupCodes();
+
+ // Encrypt the secret with v3 encryption before saving.
+ const encryptedSecret = encryptV3(secret);
+
+ // Update the user record: store the secret & backup codes and set twoFactorEnabled to false.
+ const user = await updateUser(userId, {
+ totpSecret: encryptedSecret,
+ backupCodes: codeObjects,
+ twoFactorEnabled: false,
+ });
+
+ const otpauthUrl = `otpauth://totp/${safeAppTitle}:${user.email}?secret=${secret}&issuer=${safeAppTitle}`;
+
+ return res.status(200).json({ otpauthUrl, backupCodes: plainCodes });
+ } catch (err) {
+ logger.error('[enable2FA]', err);
+ return res.status(500).json({ message: err.message });
+ }
+};
+
+/**
+ * Verify a 2FA code (either TOTP or backup code) during setup.
+ */
+const verify2FA = async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const { token, backupCode } = req.body;
+ const user = await getUserById(userId, '_id totpSecret backupCodes');
+
+ if (!user || !user.totpSecret) {
+ return res.status(400).json({ message: '2FA not initiated' });
+ }
+
+ const secret = await getTOTPSecret(user.totpSecret);
+ let isVerified = false;
+
+ if (token) {
+ isVerified = await verifyTOTP(secret, token);
+ } else if (backupCode) {
+ isVerified = await verifyBackupCode({ user, backupCode });
+ }
+
+ if (isVerified) {
+ return res.status(200).json();
+ }
+ return res.status(400).json({ message: 'Invalid token or backup code.' });
+ } catch (err) {
+ logger.error('[verify2FA]', err);
+ return res.status(500).json({ message: err.message });
+ }
+};
+
+/**
+ * Confirm and enable 2FA after a successful verification.
+ */
+const confirm2FA = async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const { token } = req.body;
+ const user = await getUserById(userId, '_id totpSecret');
+
+ if (!user || !user.totpSecret) {
+ return res.status(400).json({ message: '2FA not initiated' });
+ }
+
+ const secret = await getTOTPSecret(user.totpSecret);
+ if (await verifyTOTP(secret, token)) {
+ await updateUser(userId, { twoFactorEnabled: true });
+ return res.status(200).json();
+ }
+ return res.status(400).json({ message: 'Invalid token.' });
+ } catch (err) {
+ logger.error('[confirm2FA]', err);
+ return res.status(500).json({ message: err.message });
+ }
+};
+
+/**
+ * Disable 2FA by clearing the stored secret and backup codes.
+ * Requires verification with either TOTP token or backup code if 2FA is fully enabled.
+ */
+const disable2FA = async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const { token, backupCode } = req.body;
+ const user = await getUserById(userId, '_id totpSecret backupCodes');
+
+ if (!user || !user.totpSecret) {
+ return res.status(400).json({ message: '2FA is not setup for this user' });
+ }
+
+ if (user.twoFactorEnabled) {
+ const secret = await getTOTPSecret(user.totpSecret);
+ let isVerified = false;
+
+ if (token) {
+ isVerified = await verifyTOTP(secret, token);
+ } else if (backupCode) {
+ isVerified = await verifyBackupCode({ user, backupCode });
+ } else {
+ return res
+ .status(400)
+ .json({ message: 'Either token or backup code is required to disable 2FA' });
+ }
+
+ if (!isVerified) {
+ return res.status(401).json({ message: 'Invalid token or backup code' });
+ }
+ }
+ await updateUser(userId, { totpSecret: null, backupCodes: [], twoFactorEnabled: false });
+ return res.status(200).json();
+ } catch (err) {
+ logger.error('[disable2FA]', err);
+ return res.status(500).json({ message: err.message });
+ }
+};
+
+/**
+ * Regenerate backup codes for the user.
+ */
+const regenerateBackupCodes = async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const { plainCodes, codeObjects } = await generateBackupCodes();
+ await updateUser(userId, { backupCodes: codeObjects });
+ return res.status(200).json({
+ backupCodes: plainCodes,
+ backupCodesHash: codeObjects,
+ });
+ } catch (err) {
+ logger.error('[regenerateBackupCodes]', err);
+ return res.status(500).json({ message: err.message });
+ }
+};
+
+module.exports = {
+ enable2FA,
+ verify2FA,
+ confirm2FA,
+ disable2FA,
+ regenerateBackupCodes,
+};
diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js
new file mode 100644
index 0000000000..b0cfd7ede2
--- /dev/null
+++ b/api/server/controllers/UserController.js
@@ -0,0 +1,417 @@
+const { logger, webSearchKeys } = require('@librechat/data-schemas');
+const { Tools, CacheKeys, Constants, FileSources } = require('librechat-data-provider');
+const {
+ MCPOAuthHandler,
+ MCPTokenStorage,
+ normalizeHttpError,
+ extractWebSearchEnvVars,
+} = require('@librechat/api');
+const {
+ deleteAllUserSessions,
+ deleteAllSharedLinks,
+ updateUserPlugins,
+ deleteUserById,
+ deleteMessages,
+ deletePresets,
+ deleteUserKey,
+ deleteConvos,
+ deleteFiles,
+ updateUser,
+ findToken,
+ getFiles,
+} = require('~/models');
+const {
+ ConversationTag,
+ Transaction,
+ MemoryEntry,
+ Assistant,
+ AclEntry,
+ Balance,
+ Action,
+ Group,
+ Token,
+ User,
+} = require('~/db/models');
+const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
+const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
+const { getMCPManager, getFlowStateManager, getMCPServersRegistry } = require('~/config');
+const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
+const { processDeleteRequest } = require('~/server/services/Files/process');
+const { getAppConfig } = require('~/server/services/Config');
+const { deleteToolCalls } = require('~/models/ToolCall');
+const { deleteUserPrompts } = require('~/models/Prompt');
+const { deleteUserAgents } = require('~/models/Agent');
+const { getLogStores } = require('~/cache');
+
+const getUserController = async (req, res) => {
+ const appConfig = await getAppConfig({ role: req.user?.role });
+ /** @type {IUser} */
+ const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user };
+ /**
+ * These fields should not exist due to secure field selection, but deletion
+ * is done in case of alternate database incompatibility with Mongo API
+ * */
+ delete userData.password;
+ delete userData.totpSecret;
+ delete userData.backupCodes;
+ if (appConfig.fileStrategy === FileSources.s3 && userData.avatar) {
+ const avatarNeedsRefresh = needsRefresh(userData.avatar, 3600);
+ if (!avatarNeedsRefresh) {
+ return res.status(200).send(userData);
+ }
+ const originalAvatar = userData.avatar;
+ try {
+ userData.avatar = await getNewS3URL(userData.avatar);
+ await updateUser(userData.id, { avatar: userData.avatar });
+ } catch (error) {
+ userData.avatar = originalAvatar;
+ logger.error('Error getting new S3 URL for avatar:', error);
+ }
+ }
+ res.status(200).send(userData);
+};
+
+const getTermsStatusController = async (req, res) => {
+ try {
+ const user = await User.findById(req.user.id);
+ if (!user) {
+ return res.status(404).json({ message: 'User not found' });
+ }
+ res.status(200).json({ termsAccepted: !!user.termsAccepted });
+ } catch (error) {
+ logger.error('Error fetching terms acceptance status:', error);
+ res.status(500).json({ message: 'Error fetching terms acceptance status' });
+ }
+};
+
+const acceptTermsController = async (req, res) => {
+ try {
+ const user = await User.findByIdAndUpdate(req.user.id, { termsAccepted: true }, { new: true });
+ if (!user) {
+ return res.status(404).json({ message: 'User not found' });
+ }
+ res.status(200).json({ message: 'Terms accepted successfully' });
+ } catch (error) {
+ logger.error('Error accepting terms:', error);
+ res.status(500).json({ message: 'Error accepting terms' });
+ }
+};
+
+const deleteUserFiles = async (req) => {
+ try {
+ const userFiles = await getFiles({ user: req.user.id });
+ await processDeleteRequest({
+ req,
+ files: userFiles,
+ });
+ } catch (error) {
+ logger.error('[deleteUserFiles]', error);
+ }
+};
+
+const updateUserPluginsController = async (req, res) => {
+ const appConfig = await getAppConfig({ role: req.user?.role });
+ const { user } = req;
+ const { pluginKey, action, auth, isEntityTool } = req.body;
+ try {
+ if (!isEntityTool) {
+ await updateUserPlugins(user._id, user.plugins, pluginKey, action);
+ }
+
+ if (auth == null) {
+ return res.status(200).send();
+ }
+
+ let keys = Object.keys(auth);
+ const values = Object.values(auth); // Used in 'install' block
+
+ const isMCPTool = pluginKey.startsWith('mcp_') || pluginKey.includes(Constants.mcp_delimiter);
+
+ // Early exit condition:
+ // If keys are empty (meaning auth: {} was likely sent for uninstall, or auth was empty for install)
+ // AND it's not web_search (which has special key handling to populate `keys` for uninstall)
+ // AND it's NOT (an uninstall action FOR an MCP tool - we need to proceed for this case to clear all its auth)
+ // THEN return.
+ if (
+ keys.length === 0 &&
+ pluginKey !== Tools.web_search &&
+ !(action === 'uninstall' && isMCPTool)
+ ) {
+ return res.status(200).send();
+ }
+
+ /** @type {number} */
+ let status = 200;
+ /** @type {string} */
+ let message;
+ /** @type {IPluginAuth | Error} */
+ let authService;
+
+ if (pluginKey === Tools.web_search) {
+ /** @type {TCustomConfig['webSearch']} */
+ const webSearchConfig = appConfig?.webSearch;
+ keys = extractWebSearchEnvVars({
+ keys: action === 'install' ? keys : webSearchKeys,
+ config: webSearchConfig,
+ });
+ }
+
+ if (action === 'install') {
+ for (let i = 0; i < keys.length; i++) {
+ authService = await updateUserPluginAuth(user.id, keys[i], pluginKey, values[i]);
+ if (authService instanceof Error) {
+ logger.error('[authService]', authService);
+ ({ status, message } = normalizeHttpError(authService));
+ }
+ }
+ } else if (action === 'uninstall') {
+ // const isMCPTool was defined earlier
+ if (isMCPTool && keys.length === 0) {
+ // This handles the case where auth: {} is sent for an MCP tool uninstall.
+ // It means "delete all credentials associated with this MCP pluginKey".
+ authService = await deleteUserPluginAuth(user.id, null, true, pluginKey);
+ if (authService instanceof Error) {
+ logger.error(
+ `[authService] Error deleting all auth for MCP tool ${pluginKey}:`,
+ authService,
+ );
+ ({ status, message } = normalizeHttpError(authService));
+ }
+ try {
+ // if the MCP server uses OAuth, perform a full cleanup and token revocation
+ await maybeUninstallOAuthMCP(user.id, pluginKey, appConfig);
+ } catch (error) {
+ logger.error(
+ `[updateUserPluginsController] Error uninstalling OAuth MCP for ${pluginKey}:`,
+ error,
+ );
+ }
+ } else {
+ // This handles:
+ // 1. Web_search uninstall (keys will be populated with all webSearchKeys if auth was {}).
+ // 2. Other tools uninstall (if keys were provided).
+ // 3. MCP tool uninstall if specific keys were provided in `auth` (not current frontend behavior).
+ // If keys is empty for non-MCP tools (and not web_search), this loop won't run, and nothing is deleted.
+ for (let i = 0; i < keys.length; i++) {
+ authService = await deleteUserPluginAuth(user.id, keys[i]); // Deletes by authField name
+ if (authService instanceof Error) {
+ logger.error('[authService] Error deleting specific auth key:', authService);
+ ({ status, message } = normalizeHttpError(authService));
+ }
+ }
+ }
+ }
+
+ if (status === 200) {
+ // If auth was updated successfully, disconnect MCP sessions as they might use these credentials
+ if (pluginKey.startsWith(Constants.mcp_prefix)) {
+ try {
+ const mcpManager = getMCPManager();
+ if (mcpManager) {
+ // Extract server name from pluginKey (format: "mcp_")
+ const serverName = pluginKey.replace(Constants.mcp_prefix, '');
+ logger.info(
+ `[updateUserPluginsController] Attempting disconnect of MCP server "${serverName}" for user ${user.id} after plugin auth update.`,
+ );
+ await mcpManager.disconnectUserConnection(user.id, serverName);
+ }
+ } catch (disconnectError) {
+ logger.error(
+ `[updateUserPluginsController] Error disconnecting MCP connection for user ${user.id} after plugin auth update:`,
+ disconnectError,
+ );
+ // Do not fail the request for this, but log it.
+ }
+ }
+ return res.status(status).send();
+ }
+
+ const normalized = normalizeHttpError({ status, message });
+ return res.status(normalized.status).send({ message: normalized.message });
+ } catch (err) {
+ logger.error('[updateUserPluginsController]', err);
+ return res.status(500).json({ message: 'Something went wrong.' });
+ }
+};
+
+const deleteUserController = async (req, res) => {
+ const { user } = req;
+
+ try {
+ await deleteMessages({ user: user.id }); // delete user messages
+ await deleteAllUserSessions({ userId: user.id }); // delete user sessions
+ await Transaction.deleteMany({ user: user.id }); // delete user transactions
+ await deleteUserKey({ userId: user.id, all: true }); // delete user keys
+ await Balance.deleteMany({ user: user._id }); // delete user balances
+ await deletePresets(user.id); // delete user presets
+ try {
+ await deleteConvos(user.id); // delete user convos
+ } catch (error) {
+ logger.error('[deleteUserController] Error deleting user convos, likely no convos', error);
+ }
+ await deleteUserPluginAuth(user.id, null, true); // delete user plugin auth
+ await deleteUserById(user.id); // delete user
+ await deleteAllSharedLinks(user.id); // delete user shared links
+ await deleteUserFiles(req); // delete user files
+ await deleteFiles(null, user.id); // delete database files in case of orphaned files from previous steps
+ await deleteToolCalls(user.id); // delete user tool calls
+ await deleteUserAgents(user.id); // delete user agents
+ await Assistant.deleteMany({ user: user.id }); // delete user assistants
+ await ConversationTag.deleteMany({ user: user.id }); // delete user conversation tags
+ await MemoryEntry.deleteMany({ userId: user.id }); // delete user memory entries
+ await deleteUserPrompts(req, user.id); // delete user prompts
+ await Action.deleteMany({ user: user.id }); // delete user actions
+ await Token.deleteMany({ userId: user.id }); // delete user OAuth tokens
+ await Group.updateMany(
+ // remove user from all groups
+ { memberIds: user.id },
+ { $pull: { memberIds: user.id } },
+ );
+ await AclEntry.deleteMany({ principalId: user._id }); // delete user ACL entries
+ logger.info(`User deleted account. Email: ${user.email} ID: ${user.id}`);
+ res.status(200).send({ message: 'User deleted' });
+ } catch (err) {
+ logger.error('[deleteUserController]', err);
+ return res.status(500).json({ message: 'Something went wrong.' });
+ }
+};
+
+const verifyEmailController = async (req, res) => {
+ try {
+ const verifyEmailService = await verifyEmail(req);
+ if (verifyEmailService instanceof Error) {
+ return res.status(400).json(verifyEmailService);
+ } else {
+ return res.status(200).json(verifyEmailService);
+ }
+ } catch (e) {
+ logger.error('[verifyEmailController]', e);
+ return res.status(500).json({ message: 'Something went wrong.' });
+ }
+};
+
+const resendVerificationController = async (req, res) => {
+ try {
+ const result = await resendVerificationEmail(req);
+ if (result instanceof Error) {
+ return res.status(400).json(result);
+ } else {
+ return res.status(200).json(result);
+ }
+ } catch (e) {
+ logger.error('[verifyEmailController]', e);
+ return res.status(500).json({ message: 'Something went wrong.' });
+ }
+};
+
+/**
+ * OAuth MCP specific uninstall logic
+ */
+const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
+ if (!pluginKey.startsWith(Constants.mcp_prefix)) {
+ // this is not an MCP server, so nothing to do here
+ return;
+ }
+
+ const serverName = pluginKey.replace(Constants.mcp_prefix, '');
+ const serverConfig =
+ (await getMCPServersRegistry().getServerConfig(serverName, userId)) ??
+ appConfig?.mcpServers?.[serverName];
+ const oauthServers = await getMCPServersRegistry().getOAuthServers(userId);
+ if (!oauthServers.has(serverName)) {
+ // this server does not use OAuth, so nothing to do here as well
+ return;
+ }
+
+ // 1. get client info used for revocation (client id, secret)
+ const clientTokenData = await MCPTokenStorage.getClientInfoAndMetadata({
+ userId,
+ serverName,
+ findToken,
+ });
+ if (clientTokenData == null) {
+ return;
+ }
+ const { clientInfo, clientMetadata } = clientTokenData;
+
+ // 2. get decrypted tokens before deletion
+ const tokens = await MCPTokenStorage.getTokens({
+ userId,
+ serverName,
+ findToken,
+ });
+
+ // 3. revoke OAuth tokens at the provider
+ const revocationEndpoint =
+ serverConfig.oauth?.revocation_endpoint ?? clientMetadata.revocation_endpoint;
+ const revocationEndpointAuthMethodsSupported =
+ serverConfig.oauth?.revocation_endpoint_auth_methods_supported ??
+ clientMetadata.revocation_endpoint_auth_methods_supported;
+ const oauthHeaders = serverConfig.oauth_headers ?? {};
+
+ if (tokens?.access_token) {
+ try {
+ await MCPOAuthHandler.revokeOAuthToken(
+ serverName,
+ tokens.access_token,
+ 'access',
+ {
+ serverUrl: serverConfig.url,
+ clientId: clientInfo.client_id,
+ clientSecret: clientInfo.client_secret ?? '',
+ revocationEndpoint,
+ revocationEndpointAuthMethodsSupported,
+ },
+ oauthHeaders,
+ );
+ } catch (error) {
+ logger.error(`Error revoking OAuth access token for ${serverName}:`, error);
+ }
+ }
+
+ if (tokens?.refresh_token) {
+ try {
+ await MCPOAuthHandler.revokeOAuthToken(
+ serverName,
+ tokens.refresh_token,
+ 'refresh',
+ {
+ serverUrl: serverConfig.url,
+ clientId: clientInfo.client_id,
+ clientSecret: clientInfo.client_secret ?? '',
+ revocationEndpoint,
+ revocationEndpointAuthMethodsSupported,
+ },
+ oauthHeaders,
+ );
+ } catch (error) {
+ logger.error(`Error revoking OAuth refresh token for ${serverName}:`, error);
+ }
+ }
+
+ // 4. delete tokens from the DB after revocation attempts
+ await MCPTokenStorage.deleteUserTokens({
+ userId,
+ serverName,
+ deleteToken: async (filter) => {
+ await Token.deleteOne(filter);
+ },
+ });
+
+ // 5. clear the flow state for the OAuth tokens
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getFlowStateManager(flowsCache);
+ const flowId = MCPOAuthHandler.generateFlowId(userId, serverName);
+ await flowManager.deleteFlow(flowId, 'mcp_get_tokens');
+ await flowManager.deleteFlow(flowId, 'mcp_oauth');
+};
+
+module.exports = {
+ getUserController,
+ getTermsStatusController,
+ acceptTermsController,
+ deleteUserController,
+ verifyEmailController,
+ updateUserPluginsController,
+ resendVerificationController,
+};
diff --git a/api/server/controllers/agents/__tests__/callbacks.spec.js b/api/server/controllers/agents/__tests__/callbacks.spec.js
new file mode 100644
index 0000000000..7922c31efa
--- /dev/null
+++ b/api/server/controllers/agents/__tests__/callbacks.spec.js
@@ -0,0 +1,332 @@
+const { Tools } = require('librechat-data-provider');
+
+// Mock all dependencies before requiring the module
+jest.mock('nanoid', () => ({
+ nanoid: jest.fn(() => 'mock-id'),
+}));
+
+jest.mock('@librechat/api', () => ({
+ sendEvent: jest.fn(),
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: {
+ error: jest.fn(),
+ },
+}));
+
+jest.mock('@librechat/agents', () => ({
+ EnvVar: { CODE_API_KEY: 'CODE_API_KEY' },
+ Providers: { GOOGLE: 'google' },
+ GraphEvents: {},
+ getMessageId: jest.fn(),
+ ToolEndHandler: jest.fn(),
+ handleToolCalls: jest.fn(),
+ ChatModelStreamHandler: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/Citations', () => ({
+ processFileCitations: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/Code/process', () => ({
+ processCodeOutput: jest.fn(),
+}));
+
+jest.mock('~/server/services/Tools/credentials', () => ({
+ loadAuthValues: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/process', () => ({
+ saveBase64Image: jest.fn(),
+}));
+
+describe('createToolEndCallback', () => {
+ let req, res, artifactPromises, createToolEndCallback;
+ let logger;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Get the mocked logger
+ logger = require('@librechat/data-schemas').logger;
+
+ // Now require the module after all mocks are set up
+ const callbacks = require('../callbacks');
+ createToolEndCallback = callbacks.createToolEndCallback;
+
+ req = {
+ user: { id: 'user123' },
+ };
+ res = {
+ headersSent: false,
+ write: jest.fn(),
+ };
+ artifactPromises = [];
+ });
+
+ describe('ui_resources artifact handling', () => {
+ it('should process ui_resources artifact and return attachment when headers not sent', async () => {
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+
+ const output = {
+ tool_call_id: 'tool123',
+ artifact: {
+ [Tools.ui_resources]: {
+ data: [
+ { type: 'button', label: 'Click me' },
+ { type: 'input', placeholder: 'Enter text' },
+ ],
+ },
+ },
+ };
+
+ const metadata = {
+ run_id: 'run456',
+ thread_id: 'thread789',
+ };
+
+ await toolEndCallback({ output }, metadata);
+
+ // Wait for all promises to resolve
+ const results = await Promise.all(artifactPromises);
+
+ // When headers are not sent, it returns attachment without writing
+ expect(res.write).not.toHaveBeenCalled();
+
+ const attachment = results[0];
+ expect(attachment).toEqual({
+ type: Tools.ui_resources,
+ messageId: 'run456',
+ toolCallId: 'tool123',
+ conversationId: 'thread789',
+ [Tools.ui_resources]: [
+ { type: 'button', label: 'Click me' },
+ { type: 'input', placeholder: 'Enter text' },
+ ],
+ });
+ });
+
+ it('should write to response when headers are already sent', async () => {
+ res.headersSent = true;
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+
+ const output = {
+ tool_call_id: 'tool123',
+ artifact: {
+ [Tools.ui_resources]: {
+ data: [{ type: 'carousel', items: [] }],
+ },
+ },
+ };
+
+ const metadata = {
+ run_id: 'run456',
+ thread_id: 'thread789',
+ };
+
+ await toolEndCallback({ output }, metadata);
+ const results = await Promise.all(artifactPromises);
+
+ expect(res.write).toHaveBeenCalled();
+ expect(results[0]).toEqual({
+ type: Tools.ui_resources,
+ messageId: 'run456',
+ toolCallId: 'tool123',
+ conversationId: 'thread789',
+ [Tools.ui_resources]: [{ type: 'carousel', items: [] }],
+ });
+ });
+
+ it('should handle errors when processing ui_resources', async () => {
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+
+ // Mock res.write to throw an error
+ res.headersSent = true;
+ res.write.mockImplementation(() => {
+ throw new Error('Write failed');
+ });
+
+ const output = {
+ tool_call_id: 'tool123',
+ artifact: {
+ [Tools.ui_resources]: {
+ data: [{ type: 'test' }],
+ },
+ },
+ };
+
+ const metadata = {
+ run_id: 'run456',
+ thread_id: 'thread789',
+ };
+
+ await toolEndCallback({ output }, metadata);
+ const results = await Promise.all(artifactPromises);
+
+ expect(logger.error).toHaveBeenCalledWith(
+ 'Error processing artifact content:',
+ expect.any(Error),
+ );
+ expect(results[0]).toBeNull();
+ });
+
+ it('should handle multiple artifacts including ui_resources', async () => {
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+
+ const output = {
+ tool_call_id: 'tool123',
+ artifact: {
+ [Tools.ui_resources]: {
+ data: [{ type: 'chart', data: [] }],
+ },
+ [Tools.web_search]: {
+ results: ['result1', 'result2'],
+ },
+ },
+ };
+
+ const metadata = {
+ run_id: 'run456',
+ thread_id: 'thread789',
+ };
+
+ await toolEndCallback({ output }, metadata);
+ const results = await Promise.all(artifactPromises);
+
+ // Both ui_resources and web_search should be processed
+ expect(artifactPromises).toHaveLength(2);
+ expect(results).toHaveLength(2);
+
+ // Check ui_resources attachment
+ const uiResourceAttachment = results.find((r) => r?.type === Tools.ui_resources);
+ expect(uiResourceAttachment).toBeTruthy();
+ expect(uiResourceAttachment[Tools.ui_resources]).toEqual([{ type: 'chart', data: [] }]);
+
+ // Check web_search attachment
+ const webSearchAttachment = results.find((r) => r?.type === Tools.web_search);
+ expect(webSearchAttachment).toBeTruthy();
+ expect(webSearchAttachment[Tools.web_search]).toEqual({
+ results: ['result1', 'result2'],
+ });
+ });
+
+ it('should not process artifacts when output has no artifacts', async () => {
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+
+ const output = {
+ tool_call_id: 'tool123',
+ content: 'Some regular content',
+ // No artifact property
+ };
+
+ const metadata = {
+ run_id: 'run456',
+ thread_id: 'thread789',
+ };
+
+ await toolEndCallback({ output }, metadata);
+
+ expect(artifactPromises).toHaveLength(0);
+ expect(res.write).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle empty ui_resources data object', async () => {
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+
+ const output = {
+ tool_call_id: 'tool123',
+ artifact: {
+ [Tools.ui_resources]: {
+ data: [],
+ },
+ },
+ };
+
+ const metadata = {
+ run_id: 'run456',
+ thread_id: 'thread789',
+ };
+
+ await toolEndCallback({ output }, metadata);
+ const results = await Promise.all(artifactPromises);
+
+ expect(results[0]).toEqual({
+ type: Tools.ui_resources,
+ messageId: 'run456',
+ toolCallId: 'tool123',
+ conversationId: 'thread789',
+ [Tools.ui_resources]: [],
+ });
+ });
+
+ it('should handle ui_resources with complex nested data', async () => {
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+
+ const complexData = {
+ 0: {
+ type: 'form',
+ fields: [
+ { name: 'field1', type: 'text', required: true },
+ { name: 'field2', type: 'select', options: ['a', 'b', 'c'] },
+ ],
+ nested: {
+ deep: {
+ value: 123,
+ array: [1, 2, 3],
+ },
+ },
+ },
+ };
+
+ const output = {
+ tool_call_id: 'tool123',
+ artifact: {
+ [Tools.ui_resources]: {
+ data: complexData,
+ },
+ },
+ };
+
+ const metadata = {
+ run_id: 'run456',
+ thread_id: 'thread789',
+ };
+
+ await toolEndCallback({ output }, metadata);
+ const results = await Promise.all(artifactPromises);
+
+ expect(results[0][Tools.ui_resources]).toEqual(complexData);
+ });
+
+ it('should handle when output is undefined', async () => {
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+
+ const metadata = {
+ run_id: 'run456',
+ thread_id: 'thread789',
+ };
+
+ await toolEndCallback({ output: undefined }, metadata);
+
+ expect(artifactPromises).toHaveLength(0);
+ expect(res.write).not.toHaveBeenCalled();
+ });
+
+ it('should handle when data parameter is undefined', async () => {
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+
+ const metadata = {
+ run_id: 'run456',
+ thread_id: 'thread789',
+ };
+
+ await toolEndCallback(undefined, metadata);
+
+ expect(artifactPromises).toHaveLength(0);
+ expect(res.write).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/api/server/controllers/agents/__tests__/v1.spec.js b/api/server/controllers/agents/__tests__/v1.spec.js
new file mode 100644
index 0000000000..b7e7b67a22
--- /dev/null
+++ b/api/server/controllers/agents/__tests__/v1.spec.js
@@ -0,0 +1,195 @@
+const { duplicateAgent } = require('../v1');
+const { getAgent, createAgent } = require('~/models/Agent');
+const { getActions } = require('~/models/Action');
+const { nanoid } = require('nanoid');
+
+jest.mock('~/models/Agent');
+jest.mock('~/models/Action');
+jest.mock('nanoid');
+
+describe('duplicateAgent', () => {
+ let req, res;
+
+ beforeEach(() => {
+ req = {
+ params: { id: 'agent_123' },
+ user: { id: 'user_456' },
+ };
+ res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+ jest.clearAllMocks();
+ });
+
+ it('should duplicate an agent successfully', async () => {
+ const mockAgent = {
+ id: 'agent_123',
+ name: 'Test Agent',
+ description: 'Test Description',
+ instructions: 'Test Instructions',
+ provider: 'openai',
+ model: 'gpt-4',
+ tools: ['file_search'],
+ actions: [],
+ author: 'user_789',
+ versions: [{ name: 'Test Agent', version: 1 }],
+ __v: 0,
+ };
+
+ const mockNewAgent = {
+ id: 'agent_new_123',
+ name: 'Test Agent (1/2/23, 12:34)',
+ description: 'Test Description',
+ instructions: 'Test Instructions',
+ provider: 'openai',
+ model: 'gpt-4',
+ tools: ['file_search'],
+ actions: [],
+ author: 'user_456',
+ versions: [
+ {
+ name: 'Test Agent (1/2/23, 12:34)',
+ description: 'Test Description',
+ instructions: 'Test Instructions',
+ provider: 'openai',
+ model: 'gpt-4',
+ tools: ['file_search'],
+ actions: [],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ };
+
+ getAgent.mockResolvedValue(mockAgent);
+ getActions.mockResolvedValue([]);
+ nanoid.mockReturnValue('new_123');
+ createAgent.mockResolvedValue(mockNewAgent);
+
+ await duplicateAgent(req, res);
+
+ expect(getAgent).toHaveBeenCalledWith({ id: 'agent_123' });
+ expect(getActions).toHaveBeenCalledWith({ agent_id: 'agent_123' }, true);
+ expect(createAgent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'agent_new_123',
+ author: 'user_456',
+ name: expect.stringContaining('Test Agent ('),
+ description: 'Test Description',
+ instructions: 'Test Instructions',
+ provider: 'openai',
+ model: 'gpt-4',
+ tools: ['file_search'],
+ actions: [],
+ }),
+ );
+
+ expect(createAgent).toHaveBeenCalledWith(
+ expect.not.objectContaining({
+ versions: expect.anything(),
+ __v: expect.anything(),
+ }),
+ );
+
+ expect(res.status).toHaveBeenCalledWith(201);
+ expect(res.json).toHaveBeenCalledWith({
+ agent: mockNewAgent,
+ actions: [],
+ });
+ });
+
+ it('should ensure duplicated agent has clean versions array without nested fields', async () => {
+ const mockAgent = {
+ id: 'agent_123',
+ name: 'Test Agent',
+ description: 'Test Description',
+ versions: [
+ {
+ name: 'Test Agent',
+ versions: [{ name: 'Nested' }],
+ __v: 1,
+ },
+ ],
+ __v: 2,
+ };
+
+ const mockNewAgent = {
+ id: 'agent_new_123',
+ name: 'Test Agent (1/2/23, 12:34)',
+ description: 'Test Description',
+ versions: [
+ {
+ name: 'Test Agent (1/2/23, 12:34)',
+ description: 'Test Description',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ };
+
+ getAgent.mockResolvedValue(mockAgent);
+ getActions.mockResolvedValue([]);
+ nanoid.mockReturnValue('new_123');
+ createAgent.mockResolvedValue(mockNewAgent);
+
+ await duplicateAgent(req, res);
+
+ expect(mockNewAgent.versions).toHaveLength(1);
+
+ const firstVersion = mockNewAgent.versions[0];
+ expect(firstVersion).not.toHaveProperty('versions');
+ expect(firstVersion).not.toHaveProperty('__v');
+
+ expect(mockNewAgent).not.toHaveProperty('__v');
+
+ expect(res.status).toHaveBeenCalledWith(201);
+ });
+
+ it('should return 404 if agent not found', async () => {
+ getAgent.mockResolvedValue(null);
+
+ await duplicateAgent(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Agent not found',
+ status: 'error',
+ });
+ });
+
+ it('should convert `tool_resources.ocr` to `tool_resources.context`', async () => {
+ const mockAgent = {
+ id: 'agent_123',
+ name: 'Test Agent',
+ tool_resources: {
+ ocr: { enabled: true, config: 'test' },
+ other: { should: 'not be copied' },
+ },
+ };
+
+ getAgent.mockResolvedValue(mockAgent);
+ getActions.mockResolvedValue([]);
+ nanoid.mockReturnValue('new_123');
+ createAgent.mockResolvedValue({ id: 'agent_new_123' });
+
+ await duplicateAgent(req, res);
+
+ expect(createAgent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ tool_resources: {
+ context: { enabled: true, config: 'test' },
+ },
+ }),
+ );
+ });
+
+ it('should handle errors gracefully', async () => {
+ getAgent.mockRejectedValue(new Error('Database error'));
+
+ await duplicateAgent(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(500);
+ expect(res.json).toHaveBeenCalledWith({ error: 'Database error' });
+ });
+});
diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js
new file mode 100644
index 0000000000..4742495fc7
--- /dev/null
+++ b/api/server/controllers/agents/callbacks.js
@@ -0,0 +1,460 @@
+const { nanoid } = require('nanoid');
+const { sendEvent } = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+const { Tools, StepTypes, FileContext, ErrorTypes } = require('librechat-data-provider');
+const {
+ EnvVar,
+ Providers,
+ GraphEvents,
+ getMessageId,
+ ToolEndHandler,
+ handleToolCalls,
+ ChatModelStreamHandler,
+} = require('@librechat/agents');
+const { processFileCitations } = require('~/server/services/Files/Citations');
+const { processCodeOutput } = require('~/server/services/Files/Code/process');
+const { loadAuthValues } = require('~/server/services/Tools/credentials');
+const { saveBase64Image } = require('~/server/services/Files/process');
+
+class ModelEndHandler {
+ /**
+ * @param {Array} collectedUsage
+ */
+ constructor(collectedUsage) {
+ if (!Array.isArray(collectedUsage)) {
+ throw new Error('collectedUsage must be an array');
+ }
+ this.collectedUsage = collectedUsage;
+ }
+
+ finalize(errorMessage) {
+ if (!errorMessage) {
+ return;
+ }
+ throw new Error(errorMessage);
+ }
+
+ /**
+ * @param {string} event
+ * @param {ModelEndData | undefined} data
+ * @param {Record | undefined} metadata
+ * @param {StandardGraph} graph
+ * @returns {Promise}
+ */
+ async handle(event, data, metadata, graph) {
+ if (!graph || !metadata) {
+ console.warn(`Graph or metadata not found in ${event} event`);
+ return;
+ }
+
+ /** @type {string | undefined} */
+ let errorMessage;
+ try {
+ const agentContext = graph.getAgentContext(metadata);
+ const isGoogle = agentContext.provider === Providers.GOOGLE;
+ const streamingDisabled = !!agentContext.clientOptions?.disableStreaming;
+ if (data?.output?.additional_kwargs?.stop_reason === 'refusal') {
+ const info = { ...data.output.additional_kwargs };
+ errorMessage = JSON.stringify({
+ type: ErrorTypes.REFUSAL,
+ info,
+ });
+ logger.debug(`[ModelEndHandler] Model refused to respond`, {
+ ...info,
+ userId: metadata.user_id,
+ messageId: metadata.run_id,
+ conversationId: metadata.thread_id,
+ });
+ }
+
+ const toolCalls = data?.output?.tool_calls;
+ let hasUnprocessedToolCalls = false;
+ if (Array.isArray(toolCalls) && toolCalls.length > 0 && graph?.toolCallStepIds?.has) {
+ try {
+ hasUnprocessedToolCalls = toolCalls.some(
+ (tc) => tc?.id && !graph.toolCallStepIds.has(tc.id),
+ );
+ } catch {
+ hasUnprocessedToolCalls = false;
+ }
+ }
+ if (isGoogle || streamingDisabled || hasUnprocessedToolCalls) {
+ await handleToolCalls(toolCalls, metadata, graph);
+ }
+
+ const usage = data?.output?.usage_metadata;
+ if (!usage) {
+ return this.finalize(errorMessage);
+ }
+ const modelName = metadata?.ls_model_name || agentContext.clientOptions?.model;
+ if (modelName) {
+ usage.model = modelName;
+ }
+
+ this.collectedUsage.push(usage);
+ if (!streamingDisabled) {
+ return this.finalize(errorMessage);
+ }
+ if (!data.output.content) {
+ return this.finalize(errorMessage);
+ }
+ const stepKey = graph.getStepKey(metadata);
+ const message_id = getMessageId(stepKey, graph) ?? '';
+ if (message_id) {
+ await graph.dispatchRunStep(stepKey, {
+ type: StepTypes.MESSAGE_CREATION,
+ message_creation: {
+ message_id,
+ },
+ });
+ }
+ const stepId = graph.getStepIdByKey(stepKey);
+ const content = data.output.content;
+ if (typeof content === 'string') {
+ await graph.dispatchMessageDelta(stepId, {
+ content: [
+ {
+ type: 'text',
+ text: content,
+ },
+ ],
+ });
+ } else if (content.every((c) => c.type?.startsWith('text'))) {
+ await graph.dispatchMessageDelta(stepId, {
+ content,
+ });
+ }
+ } catch (error) {
+ logger.error('Error handling model end event:', error);
+ return this.finalize(errorMessage);
+ }
+ }
+}
+
+/**
+ * @deprecated Agent Chain helper
+ * @param {string | undefined} [last_agent_id]
+ * @param {string | undefined} [langgraph_node]
+ * @returns {boolean}
+ */
+function checkIfLastAgent(last_agent_id, langgraph_node) {
+ if (!last_agent_id || !langgraph_node) {
+ return false;
+ }
+ return langgraph_node?.endsWith(last_agent_id);
+}
+
+/**
+ * Get default handlers for stream events.
+ * @param {Object} options - The options object.
+ * @param {ServerResponse} options.res - The options object.
+ * @param {ContentAggregator} options.aggregateContent - The options object.
+ * @param {ToolEndCallback} options.toolEndCallback - Callback to use when tool ends.
+ * @param {Array} options.collectedUsage - The list of collected usage metadata.
+ * @returns {Record} The default handlers.
+ * @throws {Error} If the request is not found.
+ */
+function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedUsage }) {
+ if (!res || !aggregateContent) {
+ throw new Error(
+ `[getDefaultHandlers] Missing required options: res: ${!res}, aggregateContent: ${!aggregateContent}`,
+ );
+ }
+ const handlers = {
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(collectedUsage),
+ [GraphEvents.TOOL_END]: new ToolEndHandler(toolEndCallback, logger),
+ [GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler(),
+ [GraphEvents.ON_RUN_STEP]: {
+ /**
+ * Handle ON_RUN_STEP event.
+ * @param {string} event - The event name.
+ * @param {StreamEventData} data - The event data.
+ * @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
+ */
+ handle: (event, data, metadata) => {
+ if (data?.stepDetails.type === StepTypes.TOOL_CALLS) {
+ sendEvent(res, { event, data });
+ } else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
+ sendEvent(res, { event, data });
+ } else if (!metadata?.hide_sequential_outputs) {
+ sendEvent(res, { event, data });
+ } else {
+ const agentName = metadata?.name ?? 'Agent';
+ const isToolCall = data?.stepDetails.type === StepTypes.TOOL_CALLS;
+ const action = isToolCall ? 'performing a task...' : 'thinking...';
+ sendEvent(res, {
+ event: 'on_agent_update',
+ data: {
+ runId: metadata?.run_id,
+ message: `${agentName} is ${action}`,
+ },
+ });
+ }
+ aggregateContent({ event, data });
+ },
+ },
+ [GraphEvents.ON_RUN_STEP_DELTA]: {
+ /**
+ * Handle ON_RUN_STEP_DELTA event.
+ * @param {string} event - The event name.
+ * @param {StreamEventData} data - The event data.
+ * @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
+ */
+ handle: (event, data, metadata) => {
+ if (data?.delta.type === StepTypes.TOOL_CALLS) {
+ sendEvent(res, { event, data });
+ } else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
+ sendEvent(res, { event, data });
+ } else if (!metadata?.hide_sequential_outputs) {
+ sendEvent(res, { event, data });
+ }
+ aggregateContent({ event, data });
+ },
+ },
+ [GraphEvents.ON_RUN_STEP_COMPLETED]: {
+ /**
+ * Handle ON_RUN_STEP_COMPLETED event.
+ * @param {string} event - The event name.
+ * @param {StreamEventData & { result: ToolEndData }} data - The event data.
+ * @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
+ */
+ handle: (event, data, metadata) => {
+ if (data?.result != null) {
+ sendEvent(res, { event, data });
+ } else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
+ sendEvent(res, { event, data });
+ } else if (!metadata?.hide_sequential_outputs) {
+ sendEvent(res, { event, data });
+ }
+ aggregateContent({ event, data });
+ },
+ },
+ [GraphEvents.ON_MESSAGE_DELTA]: {
+ /**
+ * Handle ON_MESSAGE_DELTA event.
+ * @param {string} event - The event name.
+ * @param {StreamEventData} data - The event data.
+ * @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
+ */
+ handle: (event, data, metadata) => {
+ if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
+ sendEvent(res, { event, data });
+ } else if (!metadata?.hide_sequential_outputs) {
+ sendEvent(res, { event, data });
+ }
+ aggregateContent({ event, data });
+ },
+ },
+ [GraphEvents.ON_REASONING_DELTA]: {
+ /**
+ * Handle ON_REASONING_DELTA event.
+ * @param {string} event - The event name.
+ * @param {StreamEventData} data - The event data.
+ * @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
+ */
+ handle: (event, data, metadata) => {
+ if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
+ sendEvent(res, { event, data });
+ } else if (!metadata?.hide_sequential_outputs) {
+ sendEvent(res, { event, data });
+ }
+ aggregateContent({ event, data });
+ },
+ },
+ };
+
+ return handlers;
+}
+
+/**
+ *
+ * @param {Object} params
+ * @param {ServerRequest} params.req
+ * @param {ServerResponse} params.res
+ * @param {Promise[]} params.artifactPromises
+ * @returns {ToolEndCallback} The tool end callback.
+ */
+function createToolEndCallback({ req, res, artifactPromises }) {
+ /**
+ * @type {ToolEndCallback}
+ */
+ return async (data, metadata) => {
+ const output = data?.output;
+ if (!output) {
+ return;
+ }
+
+ if (!output.artifact) {
+ return;
+ }
+
+ if (output.artifact[Tools.file_search]) {
+ artifactPromises.push(
+ (async () => {
+ const user = req.user;
+ const attachment = await processFileCitations({
+ user,
+ metadata,
+ appConfig: req.config,
+ toolArtifact: output.artifact,
+ toolCallId: output.tool_call_id,
+ });
+ if (!attachment) {
+ return null;
+ }
+ if (!res.headersSent) {
+ return attachment;
+ }
+ res.write(`event: attachment\ndata: ${JSON.stringify(attachment)}\n\n`);
+ return attachment;
+ })().catch((error) => {
+ logger.error('Error processing file citations:', error);
+ return null;
+ }),
+ );
+ }
+
+ // TODO: a lot of duplicated code in createToolEndCallback
+ // we should refactor this to use a helper function in a follow-up PR
+ if (output.artifact[Tools.ui_resources]) {
+ artifactPromises.push(
+ (async () => {
+ const attachment = {
+ type: Tools.ui_resources,
+ messageId: metadata.run_id,
+ toolCallId: output.tool_call_id,
+ conversationId: metadata.thread_id,
+ [Tools.ui_resources]: output.artifact[Tools.ui_resources].data,
+ };
+ if (!res.headersSent) {
+ return attachment;
+ }
+ res.write(`event: attachment\ndata: ${JSON.stringify(attachment)}\n\n`);
+ return attachment;
+ })().catch((error) => {
+ logger.error('Error processing artifact content:', error);
+ return null;
+ }),
+ );
+ }
+
+ if (output.artifact[Tools.web_search]) {
+ artifactPromises.push(
+ (async () => {
+ const attachment = {
+ type: Tools.web_search,
+ messageId: metadata.run_id,
+ toolCallId: output.tool_call_id,
+ conversationId: metadata.thread_id,
+ [Tools.web_search]: { ...output.artifact[Tools.web_search] },
+ };
+ if (!res.headersSent) {
+ return attachment;
+ }
+ res.write(`event: attachment\ndata: ${JSON.stringify(attachment)}\n\n`);
+ return attachment;
+ })().catch((error) => {
+ logger.error('Error processing artifact content:', error);
+ return null;
+ }),
+ );
+ }
+
+ if (output.artifact.content) {
+ /** @type {FormattedContent[]} */
+ const content = output.artifact.content;
+ for (let i = 0; i < content.length; i++) {
+ const part = content[i];
+ if (!part) {
+ continue;
+ }
+ if (part.type !== 'image_url') {
+ continue;
+ }
+ const { url } = part.image_url;
+ 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,
+ });
+ const fileMetadata = Object.assign(file, {
+ messageId: metadata.run_id,
+ toolCallId: output.tool_call_id,
+ conversationId: metadata.thread_id,
+ });
+ if (!res.headersSent) {
+ return fileMetadata;
+ }
+
+ if (!fileMetadata) {
+ return null;
+ }
+
+ res.write(`event: attachment\ndata: ${JSON.stringify(fileMetadata)}\n\n`);
+ return fileMetadata;
+ })().catch((error) => {
+ logger.error('Error processing artifact content:', error);
+ return null;
+ }),
+ );
+ }
+ return;
+ }
+
+ {
+ if (output.name !== Tools.execute_code) {
+ return;
+ }
+ }
+
+ if (!output.artifact.files) {
+ return;
+ }
+
+ for (const file of output.artifact.files) {
+ const { id, name } = file;
+ artifactPromises.push(
+ (async () => {
+ const result = await loadAuthValues({
+ userId: req.user.id,
+ authFields: [EnvVar.CODE_API_KEY],
+ });
+ const fileMetadata = await processCodeOutput({
+ req,
+ id,
+ name,
+ apiKey: result[EnvVar.CODE_API_KEY],
+ messageId: metadata.run_id,
+ toolCallId: output.tool_call_id,
+ conversationId: metadata.thread_id,
+ session_id: output.artifact.session_id,
+ });
+ if (!res.headersSent) {
+ return fileMetadata;
+ }
+
+ if (!fileMetadata) {
+ return null;
+ }
+
+ res.write(`event: attachment\ndata: ${JSON.stringify(fileMetadata)}\n\n`);
+ return fileMetadata;
+ })().catch((error) => {
+ logger.error('Error processing code output:', error);
+ return null;
+ }),
+ );
+ }
+ };
+}
+
+module.exports = {
+ getDefaultHandlers,
+ createToolEndCallback,
+};
diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js
new file mode 100644
index 0000000000..faf3c58399
--- /dev/null
+++ b/api/server/controllers/agents/client.js
@@ -0,0 +1,1314 @@
+require('events').EventEmitter.defaultMaxListeners = 100;
+const { logger } = require('@librechat/data-schemas');
+const { DynamicStructuredTool } = require('@langchain/core/tools');
+const { getBufferString, HumanMessage } = require('@langchain/core/messages');
+const {
+ createRun,
+ Tokenizer,
+ checkAccess,
+ logAxiosError,
+ sanitizeTitle,
+ resolveHeaders,
+ createSafeUser,
+ initializeAgent,
+ getBalanceConfig,
+ getProviderConfig,
+ memoryInstructions,
+ getTransactionsConfig,
+ createMemoryProcessor,
+ filterMalformedContentParts,
+} = require('@librechat/api');
+const {
+ Callback,
+ Providers,
+ TitleMethod,
+ formatMessage,
+ labelContentByAgent,
+ formatAgentMessages,
+ getTokenCountForMessage,
+ createMetadataAggregator,
+} = require('@librechat/agents');
+const {
+ Constants,
+ Permissions,
+ VisionModes,
+ ContentTypes,
+ EModelEndpoint,
+ PermissionTypes,
+ isAgentsEndpoint,
+ AgentCapabilities,
+ bedrockInputSchema,
+ removeNullishValues,
+} = require('librechat-data-provider');
+const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
+const { encodeAndFormat } = require('~/server/services/Files/images/encode');
+const { createContextHandlers } = require('~/app/clients/prompts');
+const { checkCapability } = require('~/server/services/Config');
+const { getConvoFiles } = require('~/models/Conversation');
+const BaseClient = require('~/app/clients/BaseClient');
+const { getRoleByName } = require('~/models/Role');
+const { loadAgent } = require('~/models/Agent');
+const { getMCPManager } = require('~/config');
+const db = require('~/models');
+
+const omitTitleOptions = new Set([
+ 'stream',
+ 'thinking',
+ 'streaming',
+ 'clientOptions',
+ 'thinkingConfig',
+ 'thinkingBudget',
+ 'includeThoughts',
+ 'maxOutputTokens',
+ 'additionalModelRequestFields',
+]);
+
+/**
+ * @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) {
+ const parsedValues = bedrockInputSchema.parse(agent.model_parameters);
+ if (parsedValues.thinking == null) {
+ parsedValues.thinking = false;
+ }
+ return parsedValues;
+ }
+ return req.body.endpointOption.model_parameters;
+};
+
+function createTokenCounter(encoding) {
+ return function (message) {
+ const countTokens = (text) => Tokenizer.getTokenCount(text, encoding);
+ return getTokenCountForMessage(message, countTokens);
+ };
+}
+
+function logToolError(graph, error, toolId) {
+ logAxiosError({
+ error,
+ message: `[api/server/controllers/agents/client.js #chatCompletion] Tool Error "${toolId}"`,
+ });
+}
+
+/**
+ * Applies agent labeling to conversation history when multi-agent patterns are detected.
+ * Labels content parts by their originating agent to prevent identity confusion.
+ *
+ * @param {TMessage[]} orderedMessages - The ordered conversation messages
+ * @param {Agent} primaryAgent - The primary agent configuration
+ * @param {Map} agentConfigs - Map of additional agent configurations
+ * @returns {TMessage[]} Messages with agent labels applied where appropriate
+ */
+function applyAgentLabelsToHistory(orderedMessages, primaryAgent, agentConfigs) {
+ const shouldLabelByAgent = (primaryAgent.edges?.length ?? 0) > 0 || (agentConfigs?.size ?? 0) > 0;
+
+ if (!shouldLabelByAgent) {
+ return orderedMessages;
+ }
+
+ const processedMessages = [];
+
+ for (let i = 0; i < orderedMessages.length; i++) {
+ const message = orderedMessages[i];
+
+ /** @type {Record} */
+ const agentNames = { [primaryAgent.id]: primaryAgent.name || 'Assistant' };
+
+ if (agentConfigs) {
+ for (const [agentId, agentConfig] of agentConfigs.entries()) {
+ agentNames[agentId] = agentConfig.name || agentConfig.id;
+ }
+ }
+
+ if (
+ !message.isCreatedByUser &&
+ message.metadata?.agentIdMap &&
+ Array.isArray(message.content)
+ ) {
+ try {
+ const labeledContent = labelContentByAgent(
+ message.content,
+ message.metadata.agentIdMap,
+ agentNames,
+ );
+
+ processedMessages.push({ ...message, content: labeledContent });
+ } catch (error) {
+ logger.error('[AgentClient] Error applying agent labels to message:', error);
+ processedMessages.push(message);
+ }
+ } else {
+ processedMessages.push(message);
+ }
+ }
+
+ return processedMessages;
+}
+
+class AgentClient extends BaseClient {
+ constructor(options = {}) {
+ super(null, options);
+ /** The current client class
+ * @type {string} */
+ this.clientName = EModelEndpoint.agents;
+
+ /** @type {'discard' | 'summarize'} */
+ this.contextStrategy = 'discard';
+
+ /** @deprecated @type {true} - Is a Chat Completion Request */
+ this.isChatCompletion = true;
+
+ /** @type {AgentRun} */
+ this.run;
+
+ const {
+ agentConfigs,
+ contentParts,
+ collectedUsage,
+ artifactPromises,
+ maxContextTokens,
+ ...clientOptions
+ } = options;
+
+ this.agentConfigs = agentConfigs;
+ this.maxContextTokens = maxContextTokens;
+ /** @type {MessageContentComplex[]} */
+ this.contentParts = contentParts;
+ /** @type {Array} */
+ this.collectedUsage = collectedUsage;
+ /** @type {ArtifactPromises} */
+ this.artifactPromises = artifactPromises;
+ /** @type {AgentClientOptions} */
+ this.options = Object.assign({ endpoint: options.endpoint }, clientOptions);
+ /** @type {string} */
+ this.model = this.options.agent.model_parameters.model;
+ /** The key for the usage object's input tokens
+ * @type {string} */
+ this.inputTokensKey = 'input_tokens';
+ /** The key for the usage object's output tokens
+ * @type {string} */
+ this.outputTokensKey = 'output_tokens';
+ /** @type {UsageMetadata} */
+ this.usage;
+ /** @type {Record} */
+ this.indexTokenCountMap = {};
+ /** @type {(messages: BaseMessage[]) => Promise} */
+ this.processMemory;
+ /** @type {Record | null} */
+ this.agentIdMap = null;
+ }
+
+ /**
+ * Returns the aggregated content parts for the current run.
+ * @returns {MessageContentComplex[]} */
+ getContentParts() {
+ return this.contentParts;
+ }
+
+ setOptions(options) {
+ logger.info('[api/server/controllers/agents/client.js] setOptions', options);
+ }
+
+ /**
+ * `AgentClient` is not opinionated about vision requests, so we don't do anything here
+ * @param {MongoFile[]} attachments
+ */
+ checkVisionRequest() {}
+
+ getSaveOptions() {
+ // 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(
+ Object.assign(
+ {
+ endpoint: this.options.endpoint,
+ agent_id: this.options.agent.id,
+ modelLabel: this.options.modelLabel,
+ maxContextTokens: this.options.maxContextTokens,
+ resendFiles: this.options.resendFiles,
+ imageDetail: this.options.imageDetail,
+ spec: this.options.spec,
+ iconURL: this.options.iconURL,
+ },
+ // TODO: PARSE OPTIONS BY PROVIDER, MAY CONTAIN SENSITIVE DATA
+ runOptions,
+ ),
+ );
+ }
+
+ getBuildMessagesOptions() {
+ return {
+ instructions: this.options.agent.instructions,
+ additional_instructions: this.options.agent.additional_instructions,
+ };
+ }
+
+ /**
+ *
+ * @param {TMessage} message
+ * @param {Array} attachments
+ * @returns {Promise>>}
+ */
+ async addImageURLs(message, attachments) {
+ const { files, image_urls } = await encodeAndFormat(
+ this.options.req,
+ attachments,
+ {
+ provider: this.options.agent.provider,
+ endpoint: this.options.endpoint,
+ },
+ VisionModes.agents,
+ );
+ message.image_urls = image_urls.length ? image_urls : undefined;
+ return files;
+ }
+
+ async buildMessages(
+ messages,
+ parentMessageId,
+ { instructions = null, additional_instructions = null },
+ opts,
+ ) {
+ let orderedMessages = this.constructor.getMessagesForConversation({
+ messages,
+ parentMessageId,
+ summary: this.shouldSummarize,
+ });
+
+ orderedMessages = applyAgentLabelsToHistory(
+ orderedMessages,
+ this.options.agent,
+ this.agentConfigs,
+ );
+
+ let payload;
+ /** @type {number | undefined} */
+ let promptTokens;
+
+ /** @type {string} */
+ let systemContent = [instructions ?? '', additional_instructions ?? '']
+ .filter(Boolean)
+ .join('\n')
+ .trim();
+
+ if (this.options.attachments) {
+ const attachments = await this.options.attachments;
+ const latestMessage = orderedMessages[orderedMessages.length - 1];
+
+ if (this.message_file_map) {
+ this.message_file_map[latestMessage.messageId] = attachments;
+ } else {
+ this.message_file_map = {
+ [latestMessage.messageId]: attachments,
+ };
+ }
+
+ await this.addFileContextToMessage(latestMessage, attachments);
+ const files = await this.processAttachments(latestMessage, attachments);
+
+ this.options.attachments = files;
+ }
+
+ /** Note: Bedrock uses legacy RAG API handling */
+ if (this.message_file_map && !isAgentsEndpoint(this.options.endpoint)) {
+ this.contextHandlers = createContextHandlers(
+ this.options.req,
+ orderedMessages[orderedMessages.length - 1].text,
+ );
+ }
+
+ const formattedMessages = orderedMessages.map((message, i) => {
+ const formattedMessage = formatMessage({
+ message,
+ userName: this.options?.name,
+ assistantName: this.options?.modelLabel,
+ });
+
+ if (message.fileContext && i !== orderedMessages.length - 1) {
+ if (typeof formattedMessage.content === 'string') {
+ formattedMessage.content = message.fileContext + '\n' + formattedMessage.content;
+ } else {
+ const textPart = formattedMessage.content.find((part) => part.type === 'text');
+ textPart
+ ? (textPart.text = message.fileContext + '\n' + textPart.text)
+ : formattedMessage.content.unshift({ type: 'text', text: message.fileContext });
+ }
+ } else if (message.fileContext && i === orderedMessages.length - 1) {
+ systemContent = [systemContent, message.fileContext].join('\n');
+ }
+
+ const needsTokenCount =
+ (this.contextStrategy && !orderedMessages[i].tokenCount) || message.fileContext;
+
+ /* If tokens were never counted, or, is a Vision request and the message has files, count again */
+ if (needsTokenCount || (this.isVisionModel && (message.image_urls || message.files))) {
+ orderedMessages[i].tokenCount = this.getTokenCountForMessage(formattedMessage);
+ }
+
+ /* If message has files, calculate image token cost */
+ if (this.message_file_map && this.message_file_map[message.messageId]) {
+ const attachments = this.message_file_map[message.messageId];
+ for (const file of attachments) {
+ if (file.embedded) {
+ this.contextHandlers?.processFile(file);
+ continue;
+ }
+ if (file.metadata?.fileIdentifier) {
+ continue;
+ }
+ // orderedMessages[i].tokenCount += this.calculateImageTokenCost({
+ // width: file.width,
+ // height: file.height,
+ // detail: this.options.imageDetail ?? ImageDetail.auto,
+ // });
+ }
+ }
+
+ return formattedMessage;
+ });
+
+ if (this.contextHandlers) {
+ this.augmentedPrompt = await this.contextHandlers.createContext();
+ systemContent = this.augmentedPrompt + systemContent;
+ }
+
+ // Inject MCP server instructions if available
+ const ephemeralAgent = this.options.req.body.ephemeralAgent;
+ let mcpServers = [];
+
+ // Check for ephemeral agent MCP servers
+ if (ephemeralAgent && ephemeralAgent.mcp && ephemeralAgent.mcp.length > 0) {
+ mcpServers = ephemeralAgent.mcp;
+ }
+ // Check for regular agent MCP tools
+ else if (this.options.agent && this.options.agent.tools) {
+ mcpServers = this.options.agent.tools
+ .filter(
+ (tool) =>
+ tool instanceof DynamicStructuredTool && tool.name.includes(Constants.mcp_delimiter),
+ )
+ .map((tool) => tool.name.split(Constants.mcp_delimiter).pop())
+ .filter(Boolean);
+ }
+
+ if (mcpServers.length > 0) {
+ try {
+ const mcpInstructions = await getMCPManager().formatInstructionsForContext(mcpServers);
+ if (mcpInstructions) {
+ systemContent = [systemContent, mcpInstructions].filter(Boolean).join('\n\n');
+ logger.debug('[AgentClient] Injected MCP instructions for servers:', mcpServers);
+ }
+ } catch (error) {
+ logger.error('[AgentClient] Failed to inject MCP instructions:', error);
+ }
+ }
+
+ if (systemContent) {
+ this.options.agent.instructions = systemContent;
+ }
+
+ /** @type {Record | undefined} */
+ let tokenCountMap;
+
+ if (this.contextStrategy) {
+ ({ payload, promptTokens, tokenCountMap, messages } = await this.handleContextStrategy({
+ orderedMessages,
+ formattedMessages,
+ }));
+ }
+
+ for (let i = 0; i < messages.length; i++) {
+ this.indexTokenCountMap[i] = messages[i].tokenCount;
+ }
+
+ const result = {
+ tokenCountMap,
+ prompt: payload,
+ promptTokens,
+ messages,
+ };
+
+ if (promptTokens >= 0 && typeof opts?.getReqData === 'function') {
+ opts.getReqData({ promptTokens });
+ }
+
+ const withoutKeys = await this.useMemory();
+ if (withoutKeys) {
+ systemContent += `${memoryInstructions}\n\n# Existing memory about the user:\n${withoutKeys}`;
+ }
+
+ if (systemContent) {
+ this.options.agent.instructions = systemContent;
+ }
+
+ return result;
+ }
+
+ /**
+ * Creates a promise that resolves with the memory promise result or undefined after a timeout
+ * @param {Promise<(TAttachment | null)[] | undefined>} memoryPromise - The memory promise to await
+ * @param {number} timeoutMs - Timeout in milliseconds (default: 3000)
+ * @returns {Promise<(TAttachment | null)[] | undefined>}
+ */
+ async awaitMemoryWithTimeout(memoryPromise, timeoutMs = 3000) {
+ if (!memoryPromise) {
+ return;
+ }
+
+ try {
+ const timeoutPromise = new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('Memory processing timeout')), timeoutMs),
+ );
+
+ const attachments = await Promise.race([memoryPromise, timeoutPromise]);
+ return attachments;
+ } catch (error) {
+ if (error.message === 'Memory processing timeout') {
+ logger.warn('[AgentClient] Memory processing timed out after 3 seconds');
+ } else {
+ logger.error('[AgentClient] Error processing memory:', error);
+ }
+ return;
+ }
+ }
+
+ /**
+ * @returns {Promise}
+ */
+ async useMemory() {
+ const user = this.options.req.user;
+ if (user.personalization?.memories === false) {
+ return;
+ }
+ const hasAccess = await checkAccess({
+ user,
+ permissionType: PermissionTypes.MEMORIES,
+ permissions: [Permissions.USE],
+ getRoleByName,
+ });
+
+ if (!hasAccess) {
+ logger.debug(
+ `[api/server/controllers/agents/client.js #useMemory] User ${user.id} does not have USE permission for memories`,
+ );
+ return;
+ }
+ const appConfig = this.options.req.config;
+ const memoryConfig = appConfig.memory;
+ if (!memoryConfig || memoryConfig.disabled === true) {
+ return;
+ }
+
+ /** @type {Agent} */
+ let prelimAgent;
+ const allowedProviders = new Set(
+ appConfig?.endpoints?.[EModelEndpoint.agents]?.allowedProviders,
+ );
+ try {
+ if (memoryConfig.agent?.id != null && memoryConfig.agent.id !== this.options.agent.id) {
+ prelimAgent = await loadAgent({
+ req: this.options.req,
+ agent_id: memoryConfig.agent.id,
+ endpoint: EModelEndpoint.agents,
+ });
+ } else if (
+ memoryConfig.agent?.id == null &&
+ memoryConfig.agent?.model != null &&
+ memoryConfig.agent?.provider != null
+ ) {
+ prelimAgent = { id: Constants.EPHEMERAL_AGENT_ID, ...memoryConfig.agent };
+ }
+ } catch (error) {
+ logger.error(
+ '[api/server/controllers/agents/client.js #useMemory] Error loading agent for memory',
+ error,
+ );
+ }
+
+ const agent = await initializeAgent(
+ {
+ req: this.options.req,
+ res: this.options.res,
+ agent: prelimAgent,
+ allowedProviders,
+ endpointOption: {
+ endpoint:
+ prelimAgent.id !== Constants.EPHEMERAL_AGENT_ID
+ ? EModelEndpoint.agents
+ : memoryConfig.agent?.provider,
+ },
+ },
+ {
+ getConvoFiles,
+ getFiles: db.getFiles,
+ getUserKey: db.getUserKey,
+ updateFilesUsage: db.updateFilesUsage,
+ getUserKeyValues: db.getUserKeyValues,
+ getToolFilesByIds: db.getToolFilesByIds,
+ },
+ );
+
+ if (!agent) {
+ logger.warn(
+ '[api/server/controllers/agents/client.js #useMemory] No agent found for memory',
+ memoryConfig,
+ );
+ return;
+ }
+
+ const llmConfig = Object.assign(
+ {
+ provider: agent.provider,
+ model: agent.model,
+ },
+ agent.model_parameters,
+ );
+
+ /** @type {import('@librechat/api').MemoryConfig} */
+ const config = {
+ validKeys: memoryConfig.validKeys,
+ instructions: agent.instructions,
+ llmConfig,
+ tokenLimit: memoryConfig.tokenLimit,
+ };
+
+ const userId = this.options.req.user.id + '';
+ const messageId = this.responseMessageId + '';
+ const conversationId = this.conversationId + '';
+ const [withoutKeys, processMemory] = await createMemoryProcessor({
+ userId,
+ config,
+ messageId,
+ conversationId,
+ memoryMethods: {
+ setMemory: db.setMemory,
+ deleteMemory: db.deleteMemory,
+ getFormattedMemories: db.getFormattedMemories,
+ },
+ res: this.options.res,
+ });
+
+ this.processMemory = processMemory;
+ return withoutKeys;
+ }
+
+ /**
+ * Filters out image URLs from message content
+ * @param {BaseMessage} message - The message to filter
+ * @returns {BaseMessage} - A new message with image URLs removed
+ */
+ filterImageUrls(message) {
+ if (!message.content || typeof message.content === 'string') {
+ return message;
+ }
+
+ if (Array.isArray(message.content)) {
+ const filteredContent = message.content.filter(
+ (part) => part.type !== ContentTypes.IMAGE_URL,
+ );
+
+ if (filteredContent.length === 1 && filteredContent[0].type === ContentTypes.TEXT) {
+ const MessageClass = message.constructor;
+ return new MessageClass({
+ content: filteredContent[0].text,
+ additional_kwargs: message.additional_kwargs,
+ });
+ }
+
+ const MessageClass = message.constructor;
+ return new MessageClass({
+ content: filteredContent,
+ additional_kwargs: message.additional_kwargs,
+ });
+ }
+
+ return message;
+ }
+
+ /**
+ * @param {BaseMessage[]} messages
+ * @returns {Promise}
+ */
+ async runMemory(messages) {
+ try {
+ if (this.processMemory == null) {
+ return;
+ }
+ const appConfig = this.options.req.config;
+ const memoryConfig = appConfig.memory;
+ const messageWindowSize = memoryConfig?.messageWindowSize ?? 5;
+
+ let messagesToProcess = [...messages];
+ if (messages.length > messageWindowSize) {
+ for (let i = messages.length - messageWindowSize; i >= 0; i--) {
+ const potentialWindow = messages.slice(i, i + messageWindowSize);
+ if (potentialWindow[0]?.role === 'user') {
+ messagesToProcess = [...potentialWindow];
+ break;
+ }
+ }
+
+ if (messagesToProcess.length === messages.length) {
+ messagesToProcess = [...messages.slice(-messageWindowSize)];
+ }
+ }
+
+ const filteredMessages = messagesToProcess.map((msg) => this.filterImageUrls(msg));
+ const bufferString = getBufferString(filteredMessages);
+ const bufferMessage = new HumanMessage(`# Current Chat:\n\n${bufferString}`);
+ return await this.processMemory([bufferMessage]);
+ } catch (error) {
+ logger.error('Memory Agent failed to process memory', error);
+ }
+ }
+
+ /** @type {sendCompletion} */
+ async sendCompletion(payload, opts = {}) {
+ await this.chatCompletion({
+ payload,
+ onProgress: opts.onProgress,
+ userMCPAuthMap: opts.userMCPAuthMap,
+ abortController: opts.abortController,
+ });
+
+ const completion = filterMalformedContentParts(this.contentParts);
+ const metadata = this.agentIdMap ? { agentIdMap: this.agentIdMap } : undefined;
+
+ return { completion, metadata };
+ }
+
+ /**
+ * @param {Object} params
+ * @param {string} [params.model]
+ * @param {string} [params.context='message']
+ * @param {AppConfig['balance']} [params.balance]
+ * @param {AppConfig['transactions']} [params.transactions]
+ * @param {UsageMetadata[]} [params.collectedUsage=this.collectedUsage]
+ */
+ async recordCollectedUsage({
+ model,
+ balance,
+ transactions,
+ context = 'message',
+ collectedUsage = this.collectedUsage,
+ }) {
+ if (!collectedUsage || !collectedUsage.length) {
+ return;
+ }
+ const input_tokens =
+ (collectedUsage[0]?.input_tokens || 0) +
+ (Number(collectedUsage[0]?.input_token_details?.cache_creation) || 0) +
+ (Number(collectedUsage[0]?.input_token_details?.cache_read) || 0);
+
+ let output_tokens = 0;
+ let previousTokens = input_tokens; // Start with original input
+ for (let i = 0; i < collectedUsage.length; i++) {
+ const usage = collectedUsage[i];
+ if (!usage) {
+ continue;
+ }
+
+ const cache_creation = Number(usage.input_token_details?.cache_creation) || 0;
+ const cache_read = Number(usage.input_token_details?.cache_read) || 0;
+
+ const txMetadata = {
+ context,
+ balance,
+ transactions,
+ conversationId: this.conversationId,
+ user: this.user ?? this.options.req.user?.id,
+ endpointTokenConfig: this.options.endpointTokenConfig,
+ model: usage.model ?? model ?? this.model ?? this.options.agent.model_parameters.model,
+ };
+
+ if (i > 0) {
+ // Count new tokens generated (input_tokens minus previous accumulated tokens)
+ output_tokens +=
+ (Number(usage.input_tokens) || 0) + cache_creation + cache_read - previousTokens;
+ }
+
+ // Add this message's output tokens
+ output_tokens += Number(usage.output_tokens) || 0;
+
+ // Update previousTokens to include this message's output
+ previousTokens += Number(usage.output_tokens) || 0;
+
+ if (cache_creation > 0 || cache_read > 0) {
+ spendStructuredTokens(txMetadata, {
+ promptTokens: {
+ input: usage.input_tokens,
+ write: cache_creation,
+ read: cache_read,
+ },
+ completionTokens: usage.output_tokens,
+ }).catch((err) => {
+ logger.error(
+ '[api/server/controllers/agents/client.js #recordCollectedUsage] Error spending structured tokens',
+ err,
+ );
+ });
+ continue;
+ }
+ spendTokens(txMetadata, {
+ promptTokens: usage.input_tokens,
+ completionTokens: usage.output_tokens,
+ }).catch((err) => {
+ logger.error(
+ '[api/server/controllers/agents/client.js #recordCollectedUsage] Error spending tokens',
+ err,
+ );
+ });
+ }
+
+ this.usage = {
+ input_tokens,
+ output_tokens,
+ };
+ }
+
+ /**
+ * Get stream usage as returned by this client's API response.
+ * @returns {UsageMetadata} The stream usage object.
+ */
+ getStreamUsage() {
+ return this.usage;
+ }
+
+ /**
+ * @param {TMessage} responseMessage
+ * @returns {number}
+ */
+ getTokenCountForResponse({ content }) {
+ return this.getTokenCountForMessage({
+ role: 'assistant',
+ content,
+ });
+ }
+
+ /**
+ * 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.
+ * If revisiting a conversation with a chat history entirely composed of token estimates,
+ * the cumulative token count going forward should become more accurate as the conversation progresses.
+ * @param {Object} params - The parameters for the calculation.
+ * @param {Record} params.tokenCountMap - A map of message IDs to their token counts.
+ * @param {string} params.currentMessageId - The ID of the current message to calculate.
+ * @param {OpenAIUsageMetadata} params.usage - The usage object returned by the API.
+ * @returns {number} The correct token count for the current user message.
+ */
+ calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage }) {
+ const originalEstimate = tokenCountMap[currentMessageId] || 0;
+
+ if (!usage || typeof usage[this.inputTokensKey] !== 'number') {
+ return originalEstimate;
+ }
+
+ tokenCountMap[currentMessageId] = 0;
+ const totalTokensFromMap = Object.values(tokenCountMap).reduce((sum, count) => {
+ const numCount = Number(count);
+ return sum + (isNaN(numCount) ? 0 : numCount);
+ }, 0);
+ const totalInputTokens = usage[this.inputTokensKey] ?? 0;
+
+ const currentMessageTokens = totalInputTokens - totalTokensFromMap;
+ return currentMessageTokens > 0 ? currentMessageTokens : originalEstimate;
+ }
+
+ /**
+ * @param {object} params
+ * @param {string | ChatCompletionMessageParam[]} params.payload
+ * @param {Record>} [params.userMCPAuthMap]
+ * @param {AbortController} [params.abortController]
+ */
+ async chatCompletion({ payload, userMCPAuthMap, abortController = null }) {
+ /** @type {Partial} */
+ let config;
+ /** @type {ReturnType} */
+ let run;
+ /** @type {Promise<(TAttachment | null)[] | undefined>} */
+ let memoryPromise;
+ const appConfig = this.options.req.config;
+ const balanceConfig = getBalanceConfig(appConfig);
+ const transactionsConfig = getTransactionsConfig(appConfig);
+ try {
+ if (!abortController) {
+ abortController = new AbortController();
+ }
+
+ /** @type {AppConfig['endpoints']['agents']} */
+ const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents];
+
+ config = {
+ runName: 'AgentRun',
+ 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,
+ requestBody: {
+ messageId: this.responseMessageId,
+ conversationId: this.conversationId,
+ parentMessageId: this.parentMessageId,
+ },
+ user: createSafeUser(this.options.req.user),
+ },
+ recursionLimit: agentsEConfig?.recursionLimit ?? 25,
+ signal: abortController.signal,
+ streamMode: 'values',
+ version: 'v2',
+ };
+
+ const toolSet = new Set((this.options.agent.tools ?? []).map((tool) => tool && tool.name));
+ let { messages: initialMessages, indexTokenCountMap } = formatAgentMessages(
+ payload,
+ this.indexTokenCountMap,
+ toolSet,
+ );
+
+ /**
+ * @param {BaseMessage[]} messages
+ */
+ const runAgents = async (messages) => {
+ const agents = [this.options.agent];
+ if (
+ this.agentConfigs &&
+ this.agentConfigs.size > 0 &&
+ ((this.options.agent.edges?.length ?? 0) > 0 ||
+ (await checkCapability(this.options.req, AgentCapabilities.chain)))
+ ) {
+ agents.push(...this.agentConfigs.values());
+ }
+
+ if (agents[0].recursion_limit && typeof agents[0].recursion_limit === 'number') {
+ config.recursionLimit = agents[0].recursion_limit;
+ }
+
+ if (
+ agentsEConfig?.maxRecursionLimit &&
+ config.recursionLimit > agentsEConfig?.maxRecursionLimit
+ ) {
+ config.recursionLimit = agentsEConfig?.maxRecursionLimit;
+ }
+
+ // TODO: needs to be added as part of AgentContext initialization
+ // const noSystemModelRegex = [/\b(o1-preview|o1-mini|amazon\.titan-text)\b/gi];
+ // const noSystemMessages = noSystemModelRegex.some((regex) =>
+ // agent.model_parameters.model.match(regex),
+ // );
+ // if (noSystemMessages === true && systemContent?.length) {
+ // const latestMessageContent = _messages.pop().content;
+ // if (typeof latestMessageContent !== 'string') {
+ // 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));
+ // }
+ // }
+ // let messages = _messages;
+ // if (agent.useLegacyContent === true) {
+ // messages = formatContentStrings(messages);
+ // }
+ // if (
+ // agent.model_parameters?.clientOptions?.defaultHeaders?.['anthropic-beta']?.includes(
+ // 'prompt-caching',
+ // )
+ // ) {
+ // messages = addCacheControl(messages);
+ // }
+
+ memoryPromise = this.runMemory(messages);
+
+ run = await createRun({
+ agents,
+ indexTokenCountMap,
+ runId: this.responseMessageId,
+ signal: abortController.signal,
+ customHandlers: this.options.eventHandlers,
+ requestBody: config.configurable.requestBody,
+ user: createSafeUser(this.options.req?.user),
+ tokenCounter: createTokenCounter(this.getEncoding()),
+ });
+
+ if (!run) {
+ throw new Error('Failed to create run');
+ }
+
+ this.run = run;
+ if (userMCPAuthMap != null) {
+ config.configurable.userMCPAuthMap = userMCPAuthMap;
+ }
+
+ /** @deprecated Agent Chain */
+ config.configurable.last_agent_id = agents[agents.length - 1].id;
+ await run.processStream({ messages }, config, {
+ callbacks: {
+ [Callback.TOOL_ERROR]: logToolError,
+ },
+ });
+
+ config.signal = null;
+ };
+
+ await runAgents(initialMessages);
+ /** @deprecated Agent Chain */
+ if (config.configurable.hide_sequential_outputs) {
+ this.contentParts = this.contentParts.filter((part, index) => {
+ // Include parts that are either:
+ // 1. At or after the finalContentStart index
+ // 2. Of type tool_call
+ // 3. Have tool_call_ids property
+ return (
+ index >= this.contentParts.length - 1 ||
+ part.type === ContentTypes.TOOL_CALL ||
+ part.tool_call_ids
+ );
+ });
+ }
+
+ try {
+ /** Capture agent ID map if we have edges or multiple agents */
+ const shouldStoreAgentMap =
+ (this.options.agent.edges?.length ?? 0) > 0 || (this.agentConfigs?.size ?? 0) > 0;
+ if (shouldStoreAgentMap && run?.Graph) {
+ const contentPartAgentMap = run.Graph.getContentPartAgentMap();
+ if (contentPartAgentMap && contentPartAgentMap.size > 0) {
+ this.agentIdMap = Object.fromEntries(contentPartAgentMap);
+ logger.debug('[AgentClient] Captured agent ID map:', {
+ totalParts: this.contentParts.length,
+ mappedParts: Object.keys(this.agentIdMap).length,
+ });
+ }
+ }
+ } catch (error) {
+ logger.error('[AgentClient] Error capturing agent ID map:', error);
+ }
+ } catch (err) {
+ logger.error(
+ '[api/server/controllers/agents/client.js #sendCompletion] Operation aborted',
+ err,
+ );
+ if (!abortController.signal.aborted) {
+ logger.error(
+ '[api/server/controllers/agents/client.js #sendCompletion] Unhandled error type',
+ err,
+ );
+ this.contentParts.push({
+ type: ContentTypes.ERROR,
+ [ContentTypes.ERROR]: `An error occurred while processing the request${err?.message ? `: ${err.message}` : ''}`,
+ });
+ }
+ } finally {
+ try {
+ const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
+ if (attachments && attachments.length > 0) {
+ this.artifactPromises.push(...attachments);
+ }
+
+ await this.recordCollectedUsage({
+ context: 'message',
+ balance: balanceConfig,
+ transactions: transactionsConfig,
+ });
+ } catch (err) {
+ logger.error(
+ '[api/server/controllers/agents/client.js #chatCompletion] Error in cleanup phase',
+ err,
+ );
+ }
+ run = null;
+ config = null;
+ memoryPromise = null;
+ }
+ }
+
+ /**
+ *
+ * @param {Object} params
+ * @param {string} params.text
+ * @param {string} params.conversationId
+ */
+ async titleConvo({ text, abortController }) {
+ if (!this.run) {
+ throw new Error('Run not initialized');
+ }
+ const { handleLLMEnd, collected: collectedMetadata } = createMetadataAggregator();
+ const { req, agent } = this.options;
+ const appConfig = req.config;
+ let endpoint = agent.endpoint;
+
+ /** @type {import('@librechat/agents').ClientOptions} */
+ let clientOptions = {
+ model: agent.model || agent.model_parameters.model,
+ };
+
+ let titleProviderConfig = getProviderConfig({ provider: endpoint, appConfig });
+
+ /** @type {TEndpoint | undefined} */
+ const endpointConfig =
+ appConfig.endpoints?.all ??
+ appConfig.endpoints?.[endpoint] ??
+ titleProviderConfig.customEndpointConfig;
+ if (!endpointConfig) {
+ logger.debug(
+ `[api/server/controllers/agents/client.js #titleConvo] No endpoint config for "${endpoint}"`,
+ );
+ }
+
+ if (endpointConfig?.titleConvo === false) {
+ logger.debug(
+ `[api/server/controllers/agents/client.js #titleConvo] Title generation disabled for endpoint "${endpoint}"`,
+ );
+ return;
+ }
+
+ if (endpointConfig?.titleEndpoint && endpointConfig.titleEndpoint !== endpoint) {
+ try {
+ titleProviderConfig = getProviderConfig({
+ provider: endpointConfig.titleEndpoint,
+ appConfig,
+ });
+ endpoint = endpointConfig.titleEndpoint;
+ } catch (error) {
+ logger.warn(
+ `[api/server/controllers/agents/client.js #titleConvo] Error getting title endpoint config for "${endpointConfig.titleEndpoint}", falling back to default`,
+ error,
+ );
+ // Fall back to original provider config
+ endpoint = agent.endpoint;
+ titleProviderConfig = getProviderConfig({ provider: endpoint, appConfig });
+ }
+ }
+
+ if (
+ endpointConfig &&
+ endpointConfig.titleModel &&
+ endpointConfig.titleModel !== Constants.CURRENT_MODEL
+ ) {
+ clientOptions.model = endpointConfig.titleModel;
+ }
+
+ const options = await titleProviderConfig.getOptions({
+ req,
+ endpoint,
+ model_parameters: clientOptions,
+ db: {
+ getUserKey: db.getUserKey,
+ getUserKeyValues: db.getUserKeyValues,
+ },
+ });
+
+ let provider = options.provider ?? titleProviderConfig.overrideProvider ?? agent.provider;
+ if (
+ endpoint === EModelEndpoint.azureOpenAI &&
+ options.llmConfig?.azureOpenAIApiInstanceName == null
+ ) {
+ provider = Providers.OPENAI;
+ } else if (
+ endpoint === EModelEndpoint.azureOpenAI &&
+ options.llmConfig?.azureOpenAIApiInstanceName != null &&
+ provider !== Providers.AZURE
+ ) {
+ provider = Providers.AZURE;
+ }
+
+ /** @type {import('@librechat/agents').ClientOptions} */
+ clientOptions = { ...options.llmConfig };
+ if (options.configOptions) {
+ clientOptions.configuration = options.configOptions;
+ }
+
+ if (clientOptions.maxTokens != null) {
+ delete clientOptions.maxTokens;
+ }
+ if (clientOptions?.modelKwargs?.max_completion_tokens != null) {
+ delete clientOptions.modelKwargs.max_completion_tokens;
+ }
+ if (clientOptions?.modelKwargs?.max_output_tokens != null) {
+ delete clientOptions.modelKwargs.max_output_tokens;
+ }
+
+ clientOptions = Object.assign(
+ Object.fromEntries(
+ Object.entries(clientOptions).filter(([key]) => !omitTitleOptions.has(key)),
+ ),
+ );
+
+ if (
+ provider === Providers.GOOGLE &&
+ (endpointConfig?.titleMethod === TitleMethod.FUNCTIONS ||
+ endpointConfig?.titleMethod === TitleMethod.STRUCTURED)
+ ) {
+ clientOptions.json = true;
+ }
+
+ /** Resolve request-based headers for Custom Endpoints. Note: if this is added to
+ * non-custom endpoints, needs consideration of varying provider header configs.
+ */
+ if (clientOptions?.configuration?.defaultHeaders != null) {
+ clientOptions.configuration.defaultHeaders = resolveHeaders({
+ headers: clientOptions.configuration.defaultHeaders,
+ user: createSafeUser(this.options.req?.user),
+ body: {
+ messageId: this.responseMessageId,
+ conversationId: this.conversationId,
+ parentMessageId: this.parentMessageId,
+ },
+ });
+ }
+
+ try {
+ const titleResult = await this.run.generateTitle({
+ provider,
+ clientOptions,
+ inputText: text,
+ contentParts: this.contentParts,
+ titleMethod: endpointConfig?.titleMethod,
+ titlePrompt: endpointConfig?.titlePrompt,
+ titlePromptTemplate: endpointConfig?.titlePromptTemplate,
+ chainOptions: {
+ signal: abortController.signal,
+ callbacks: [
+ {
+ handleLLMEnd,
+ },
+ ],
+ configurable: {
+ thread_id: this.conversationId,
+ user_id: this.user ?? this.options.req.user?.id,
+ },
+ },
+ });
+
+ const collectedUsage = collectedMetadata.map((item) => {
+ let input_tokens, output_tokens;
+
+ if (item.usage) {
+ input_tokens =
+ item.usage.prompt_tokens || item.usage.input_tokens || item.usage.inputTokens;
+ output_tokens =
+ item.usage.completion_tokens || item.usage.output_tokens || item.usage.outputTokens;
+ } else if (item.tokenUsage) {
+ input_tokens = item.tokenUsage.promptTokens;
+ output_tokens = item.tokenUsage.completionTokens;
+ }
+
+ return {
+ input_tokens: input_tokens,
+ output_tokens: output_tokens,
+ };
+ });
+
+ const balanceConfig = getBalanceConfig(appConfig);
+ const transactionsConfig = getTransactionsConfig(appConfig);
+ await this.recordCollectedUsage({
+ collectedUsage,
+ context: 'title',
+ model: clientOptions.model,
+ balance: balanceConfig,
+ transactions: transactionsConfig,
+ }).catch((err) => {
+ logger.error(
+ '[api/server/controllers/agents/client.js #titleConvo] Error recording collected usage',
+ err,
+ );
+ });
+
+ return sanitizeTitle(titleResult.title);
+ } catch (err) {
+ logger.error('[api/server/controllers/agents/client.js #titleConvo] Error', err);
+ return;
+ }
+ }
+
+ /**
+ * @param {object} params
+ * @param {number} params.promptTokens
+ * @param {number} params.completionTokens
+ * @param {string} [params.model]
+ * @param {OpenAIUsageMetadata} [params.usage]
+ * @param {AppConfig['balance']} [params.balance]
+ * @param {string} [params.context='message']
+ * @returns {Promise}
+ */
+ async recordTokenUsage({
+ model,
+ usage,
+ balance,
+ promptTokens,
+ completionTokens,
+ context = 'message',
+ }) {
+ try {
+ await spendTokens(
+ {
+ model,
+ context,
+ balance,
+ conversationId: this.conversationId,
+ user: this.user ?? this.options.req.user?.id,
+ endpointTokenConfig: this.options.endpointTokenConfig,
+ },
+ { promptTokens, completionTokens },
+ );
+
+ if (
+ usage &&
+ typeof usage === 'object' &&
+ 'reasoning_tokens' in usage &&
+ typeof usage.reasoning_tokens === 'number'
+ ) {
+ await spendTokens(
+ {
+ model,
+ balance,
+ context: 'reasoning',
+ conversationId: this.conversationId,
+ user: this.user ?? this.options.req.user?.id,
+ endpointTokenConfig: this.options.endpointTokenConfig,
+ },
+ { completionTokens: usage.reasoning_tokens },
+ );
+ }
+ } catch (error) {
+ logger.error(
+ '[api/server/controllers/agents/client.js #recordTokenUsage] Error recording token usage',
+ error,
+ );
+ }
+ }
+
+ getEncoding() {
+ return 'o200k_base';
+ }
+
+ /**
+ * Returns the token count of a given text. It also checks and resets the tokenizers if necessary.
+ * @param {string} text - The text to get the token count for.
+ * @returns {number} The token count of the given text.
+ */
+ getTokenCount(text) {
+ const encoding = this.getEncoding();
+ return Tokenizer.getTokenCount(text, encoding);
+ }
+}
+
+module.exports = AgentClient;
diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js
new file mode 100644
index 0000000000..0ce59c5fbc
--- /dev/null
+++ b/api/server/controllers/agents/client.test.js
@@ -0,0 +1,1614 @@
+const { Providers } = require('@librechat/agents');
+const { Constants, EModelEndpoint } = require('librechat-data-provider');
+const AgentClient = require('./client');
+
+jest.mock('@librechat/agents', () => ({
+ ...jest.requireActual('@librechat/agents'),
+ createMetadataAggregator: () => ({
+ handleLLMEnd: jest.fn(),
+ collected: [],
+ }),
+}));
+
+jest.mock('@librechat/api', () => ({
+ ...jest.requireActual('@librechat/api'),
+}));
+
+// Mock getMCPManager
+const mockFormatInstructions = jest.fn();
+jest.mock('~/config', () => ({
+ getMCPManager: jest.fn(() => ({
+ formatInstructionsForContext: mockFormatInstructions,
+ })),
+}));
+
+describe('AgentClient - titleConvo', () => {
+ let client;
+ let mockRun;
+ let mockReq;
+ let mockRes;
+ let mockAgent;
+ let mockOptions;
+
+ beforeEach(() => {
+ // Reset all mocks
+ jest.clearAllMocks();
+
+ // Mock run object
+ mockRun = {
+ generateTitle: jest.fn().mockResolvedValue({
+ title: 'Generated Title',
+ }),
+ };
+
+ // Mock agent - with both endpoint and provider
+ mockAgent = {
+ id: 'agent-123',
+ endpoint: EModelEndpoint.openAI, // Use a valid provider as endpoint for getProviderConfig
+ provider: EModelEndpoint.openAI, // Add provider property
+ model_parameters: {
+ model: 'gpt-4',
+ },
+ };
+
+ // Mock request and response
+ mockReq = {
+ user: {
+ id: 'user-123',
+ },
+ body: {
+ model: 'gpt-4',
+ endpoint: EModelEndpoint.openAI,
+ key: null,
+ },
+ config: {
+ endpoints: {
+ [EModelEndpoint.openAI]: {
+ // Match the agent endpoint
+ titleModel: 'gpt-3.5-turbo',
+ titlePrompt: 'Custom title prompt',
+ titleMethod: 'structured',
+ titlePromptTemplate: 'Template: {{content}}',
+ },
+ },
+ },
+ };
+
+ mockRes = {};
+
+ // Mock options
+ mockOptions = {
+ req: mockReq,
+ res: mockRes,
+ agent: mockAgent,
+ endpointTokenConfig: {},
+ };
+
+ // Create client instance
+ client = new AgentClient(mockOptions);
+ client.run = mockRun;
+ client.responseMessageId = 'response-123';
+ client.conversationId = 'convo-123';
+ client.contentParts = [{ type: 'text', text: 'Test content' }];
+ client.recordCollectedUsage = jest.fn().mockResolvedValue(); // Mock as async function that resolves
+ });
+
+ describe('titleConvo method', () => {
+ it('should throw error if run is not initialized', async () => {
+ client.run = null;
+
+ await expect(
+ client.titleConvo({ text: 'Test', abortController: new AbortController() }),
+ ).rejects.toThrow('Run not initialized');
+ });
+
+ it('should use titlePrompt from endpoint config', async () => {
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ titlePrompt: 'Custom title prompt',
+ }),
+ );
+ });
+
+ it('should use titlePromptTemplate from endpoint config', async () => {
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ titlePromptTemplate: 'Template: {{content}}',
+ }),
+ );
+ });
+
+ it('should use titleMethod from endpoint config', async () => {
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: Providers.OPENAI,
+ titleMethod: 'structured',
+ }),
+ );
+ });
+
+ it('should use titleModel from endpoint config when provided', async () => {
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Check that generateTitle was called with correct clientOptions
+ const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
+ expect(generateTitleCall.clientOptions.model).toBe('gpt-3.5-turbo');
+ });
+
+ it('should handle missing endpoint config gracefully', async () => {
+ // Remove endpoint config
+ mockReq.config = { endpoints: {} };
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ titlePrompt: undefined,
+ titlePromptTemplate: undefined,
+ titleMethod: undefined,
+ }),
+ );
+ });
+
+ it('should use agent model when titleModel is not provided', async () => {
+ // Remove titleModel from config
+ mockReq.config = {
+ endpoints: {
+ [EModelEndpoint.openAI]: {
+ titlePrompt: 'Custom title prompt',
+ titleMethod: 'structured',
+ titlePromptTemplate: 'Template: {{content}}',
+ // titleModel is omitted
+ },
+ },
+ };
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
+ expect(generateTitleCall.clientOptions.model).toBe('gpt-4'); // Should use agent's model
+ });
+
+ it('should not use titleModel when it equals CURRENT_MODEL constant', async () => {
+ mockReq.config = {
+ endpoints: {
+ [EModelEndpoint.openAI]: {
+ titleModel: Constants.CURRENT_MODEL,
+ titlePrompt: 'Custom title prompt',
+ titleMethod: 'structured',
+ titlePromptTemplate: 'Template: {{content}}',
+ },
+ },
+ };
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
+ expect(generateTitleCall.clientOptions.model).toBe('gpt-4'); // Should use agent's model
+ });
+
+ it('should pass all required parameters to generateTitle', async () => {
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ expect(mockRun.generateTitle).toHaveBeenCalledWith({
+ provider: expect.any(String),
+ inputText: text,
+ contentParts: client.contentParts,
+ clientOptions: expect.objectContaining({
+ model: 'gpt-3.5-turbo',
+ }),
+ titlePrompt: 'Custom title prompt',
+ titlePromptTemplate: 'Template: {{content}}',
+ titleMethod: 'structured',
+ chainOptions: expect.objectContaining({
+ signal: abortController.signal,
+ }),
+ });
+ });
+
+ it('should record collected usage after title generation', async () => {
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ expect(client.recordCollectedUsage).toHaveBeenCalledWith({
+ model: 'gpt-3.5-turbo',
+ context: 'title',
+ collectedUsage: expect.any(Array),
+ balance: {
+ enabled: false,
+ },
+ transactions: {
+ enabled: true,
+ },
+ });
+ });
+
+ it('should return the generated title', async () => {
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ const result = await client.titleConvo({ text, abortController });
+
+ expect(result).toBe('Generated Title');
+ });
+
+ it('should sanitize the generated title by removing think blocks', async () => {
+ const titleWithThinkBlock = 'reasoning about the title User Hi Greeting';
+ mockRun.generateTitle.mockResolvedValue({
+ title: titleWithThinkBlock,
+ });
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ const result = await client.titleConvo({ text, abortController });
+
+ // Should remove the block and return only the clean title
+ expect(result).toBe('User Hi Greeting');
+ expect(result).not.toContain('');
+ expect(result).not.toContain(' ');
+ });
+
+ it('should return fallback title when sanitization results in empty string', async () => {
+ const titleOnlyThinkBlock = 'only reasoning no actual title ';
+ mockRun.generateTitle.mockResolvedValue({
+ title: titleOnlyThinkBlock,
+ });
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ const result = await client.titleConvo({ text, abortController });
+
+ // Should return the fallback title since sanitization would result in empty string
+ expect(result).toBe('Untitled Conversation');
+ });
+
+ it('should handle errors gracefully and return undefined', async () => {
+ mockRun.generateTitle.mockRejectedValue(new Error('Title generation failed'));
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ const result = await client.titleConvo({ text, abortController });
+
+ expect(result).toBeUndefined();
+ });
+
+ it('should skip title generation when titleConvo is set to false', async () => {
+ // Set titleConvo to false in endpoint config
+ mockReq.config = {
+ endpoints: {
+ [EModelEndpoint.openAI]: {
+ titleConvo: false,
+ titleModel: 'gpt-3.5-turbo',
+ titlePrompt: 'Custom title prompt',
+ titleMethod: 'structured',
+ titlePromptTemplate: 'Template: {{content}}',
+ },
+ },
+ };
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ const result = await client.titleConvo({ text, abortController });
+
+ // Should return undefined without generating title
+ expect(result).toBeUndefined();
+
+ // generateTitle should NOT have been called
+ expect(mockRun.generateTitle).not.toHaveBeenCalled();
+
+ // recordCollectedUsage should NOT have been called
+ expect(client.recordCollectedUsage).not.toHaveBeenCalled();
+ });
+
+ it('should skip title generation when titleConvo is false in all config', async () => {
+ // Set titleConvo to false in "all" config
+ mockReq.config = {
+ endpoints: {
+ all: {
+ titleConvo: false,
+ titleModel: 'gpt-4o-mini',
+ titlePrompt: 'All config title prompt',
+ titleMethod: 'completion',
+ titlePromptTemplate: 'All config template',
+ },
+ },
+ };
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ const result = await client.titleConvo({ text, abortController });
+
+ // Should return undefined without generating title
+ expect(result).toBeUndefined();
+
+ // generateTitle should NOT have been called
+ expect(mockRun.generateTitle).not.toHaveBeenCalled();
+
+ // recordCollectedUsage should NOT have been called
+ expect(client.recordCollectedUsage).not.toHaveBeenCalled();
+ });
+
+ it('should skip title generation when titleConvo is false for custom endpoint scenario', async () => {
+ // This test validates the behavior when customEndpointConfig (retrieved via
+ // getProviderConfig for custom endpoints) has titleConvo: false.
+ //
+ // The code path is:
+ // 1. endpoints?.all is checked (undefined in this test)
+ // 2. endpoints?.[endpoint] is checked (our test config)
+ // 3. Would fall back to titleProviderConfig.customEndpointConfig (for real custom endpoints)
+ //
+ // We simulate a custom endpoint scenario using a dynamically named endpoint config
+
+ // Create a unique endpoint name that represents a custom endpoint
+ const customEndpointName = 'customEndpoint';
+
+ // Configure the endpoint to have titleConvo: false
+ // This simulates what would be in customEndpointConfig for a real custom endpoint
+ mockReq.config = {
+ endpoints: {
+ // No 'all' config - so it will check endpoints[endpoint]
+ // This config represents what customEndpointConfig would contain
+ [customEndpointName]: {
+ titleConvo: false,
+ titleModel: 'custom-model-v1',
+ titlePrompt: 'Custom endpoint title prompt',
+ titleMethod: 'completion',
+ titlePromptTemplate: 'Custom template: {{content}}',
+ baseURL: 'https://api.custom-llm.com/v1',
+ apiKey: 'test-custom-key',
+ // Additional custom endpoint properties
+ models: {
+ default: ['custom-model-v1', 'custom-model-v2'],
+ },
+ },
+ },
+ };
+
+ // Set up agent to use our custom endpoint
+ // Use openAI as base but override with custom endpoint name for this test
+ mockAgent.endpoint = EModelEndpoint.openAI;
+ mockAgent.provider = EModelEndpoint.openAI;
+
+ // Override the endpoint in the config to point to our custom config
+ mockReq.config.endpoints[EModelEndpoint.openAI] =
+ mockReq.config.endpoints[customEndpointName];
+ delete mockReq.config.endpoints[customEndpointName];
+
+ const text = 'Test custom endpoint conversation';
+ const abortController = new AbortController();
+
+ const result = await client.titleConvo({ text, abortController });
+
+ // Should return undefined without generating title because titleConvo is false
+ expect(result).toBeUndefined();
+
+ // generateTitle should NOT have been called
+ expect(mockRun.generateTitle).not.toHaveBeenCalled();
+
+ // recordCollectedUsage should NOT have been called
+ expect(client.recordCollectedUsage).not.toHaveBeenCalled();
+ });
+
+ it('should pass titleEndpoint configuration to generateTitle', async () => {
+ // Mock the API key just for this test
+ const originalApiKey = process.env.ANTHROPIC_API_KEY;
+ process.env.ANTHROPIC_API_KEY = 'test-api-key';
+
+ // Add titleEndpoint to the config
+ mockReq.config = {
+ endpoints: {
+ [EModelEndpoint.openAI]: {
+ titleModel: 'gpt-3.5-turbo',
+ titleEndpoint: EModelEndpoint.anthropic,
+ titleMethod: 'structured',
+ titlePrompt: 'Custom title prompt',
+ titlePromptTemplate: 'Custom template',
+ },
+ },
+ };
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Verify generateTitle was called with the custom configuration
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ titleMethod: 'structured',
+ provider: Providers.ANTHROPIC,
+ titlePrompt: 'Custom title prompt',
+ titlePromptTemplate: 'Custom template',
+ }),
+ );
+
+ // Restore the original API key
+ if (originalApiKey) {
+ process.env.ANTHROPIC_API_KEY = originalApiKey;
+ } else {
+ delete process.env.ANTHROPIC_API_KEY;
+ }
+ });
+
+ it('should use all config when endpoint config is missing', async () => {
+ // Set 'all' config without endpoint-specific config
+ mockReq.config = {
+ endpoints: {
+ all: {
+ titleModel: 'gpt-4o-mini',
+ titlePrompt: 'All config title prompt',
+ titleMethod: 'completion',
+ titlePromptTemplate: 'All config template: {{content}}',
+ },
+ },
+ };
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Verify generateTitle was called with 'all' config values
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ titleMethod: 'completion',
+ titlePrompt: 'All config title prompt',
+ titlePromptTemplate: 'All config template: {{content}}',
+ }),
+ );
+
+ // Check that the model was set from 'all' config
+ const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
+ expect(generateTitleCall.clientOptions.model).toBe('gpt-4o-mini');
+ });
+
+ it('should prioritize all config over endpoint config for title settings', async () => {
+ // Set both endpoint and 'all' config
+ mockReq.config = {
+ endpoints: {
+ [EModelEndpoint.openAI]: {
+ titleModel: 'gpt-3.5-turbo',
+ titlePrompt: 'Endpoint title prompt',
+ titleMethod: 'structured',
+ // titlePromptTemplate is omitted to test fallback
+ },
+ all: {
+ titleModel: 'gpt-4o-mini',
+ titlePrompt: 'All config title prompt',
+ titleMethod: 'completion',
+ titlePromptTemplate: 'All config template',
+ },
+ },
+ };
+
+ const text = 'Test conversation text';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Verify 'all' config takes precedence over endpoint config
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ titleMethod: 'completion',
+ titlePrompt: 'All config title prompt',
+ titlePromptTemplate: 'All config template',
+ }),
+ );
+
+ // Check that the model was set from 'all' config
+ const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
+ expect(generateTitleCall.clientOptions.model).toBe('gpt-4o-mini');
+ });
+
+ it('should use all config with titleEndpoint and verify provider switch', async () => {
+ // Mock the API key for the titleEndpoint provider
+ const originalApiKey = process.env.ANTHROPIC_API_KEY;
+ process.env.ANTHROPIC_API_KEY = 'test-anthropic-key';
+
+ // Set comprehensive 'all' config with all new title options
+ mockReq.config = {
+ endpoints: {
+ all: {
+ titleConvo: true,
+ titleModel: 'claude-3-haiku-20240307',
+ titleMethod: 'completion', // Testing the new default method
+ titlePrompt: 'Generate a concise, descriptive title for this conversation',
+ titlePromptTemplate: 'Conversation summary: {{content}}',
+ titleEndpoint: EModelEndpoint.anthropic, // Should switch provider to Anthropic
+ },
+ },
+ };
+
+ const text = 'Test conversation about AI and machine learning';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Verify all config values were used
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: Providers.ANTHROPIC, // Critical: Verify provider switched to Anthropic
+ titleMethod: 'completion',
+ titlePrompt: 'Generate a concise, descriptive title for this conversation',
+ titlePromptTemplate: 'Conversation summary: {{content}}',
+ inputText: text,
+ contentParts: client.contentParts,
+ }),
+ );
+
+ // Verify the model was set from 'all' config
+ const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
+ expect(generateTitleCall.clientOptions.model).toBe('claude-3-haiku-20240307');
+
+ // Verify other client options are set correctly
+ expect(generateTitleCall.clientOptions).toMatchObject({
+ model: 'claude-3-haiku-20240307',
+ // Note: Anthropic's getOptions may set its own maxTokens value
+ });
+
+ // Restore the original API key
+ if (originalApiKey) {
+ process.env.ANTHROPIC_API_KEY = originalApiKey;
+ } else {
+ delete process.env.ANTHROPIC_API_KEY;
+ }
+ });
+
+ it('should test all titleMethod options from all config', async () => {
+ // Test each titleMethod: 'completion', 'functions', 'structured'
+ const titleMethods = ['completion', 'functions', 'structured'];
+
+ for (const method of titleMethods) {
+ // Clear previous calls
+ mockRun.generateTitle.mockClear();
+
+ // Set 'all' config with specific titleMethod
+ mockReq.config = {
+ endpoints: {
+ all: {
+ titleModel: 'gpt-4o-mini',
+ titleMethod: method,
+ titlePrompt: `Testing ${method} method`,
+ titlePromptTemplate: `Template for ${method}: {{content}}`,
+ },
+ },
+ };
+
+ const text = `Test conversation for ${method} method`;
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Verify the correct titleMethod was used
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ titleMethod: method,
+ titlePrompt: `Testing ${method} method`,
+ titlePromptTemplate: `Template for ${method}: {{content}}`,
+ }),
+ );
+ }
+ });
+
+ describe('Azure-specific title generation', () => {
+ let originalEnv;
+
+ beforeEach(() => {
+ // Reset mocks
+ jest.clearAllMocks();
+
+ // Save original environment variables
+ originalEnv = { ...process.env };
+
+ // Mock Azure API keys
+ process.env.AZURE_OPENAI_API_KEY = 'test-azure-key';
+ process.env.AZURE_API_KEY = 'test-azure-key';
+ process.env.EASTUS_API_KEY = 'test-eastus-key';
+ process.env.EASTUS2_API_KEY = 'test-eastus2-key';
+ });
+
+ afterEach(() => {
+ // Restore environment variables
+ process.env = originalEnv;
+ });
+
+ it('should use OPENAI provider for Azure serverless endpoints', async () => {
+ // Set up Azure endpoint with serverless config
+ mockAgent.endpoint = EModelEndpoint.azureOpenAI;
+ mockAgent.provider = EModelEndpoint.azureOpenAI;
+ mockReq.config = {
+ endpoints: {
+ [EModelEndpoint.azureOpenAI]: {
+ titleConvo: true,
+ titleModel: 'grok-3',
+ titleMethod: 'completion',
+ titlePrompt: 'Azure serverless title prompt',
+ streamRate: 35,
+ modelGroupMap: {
+ 'grok-3': {
+ group: 'Azure AI Foundry',
+ deploymentName: 'grok-3',
+ },
+ },
+ groupMap: {
+ 'Azure AI Foundry': {
+ apiKey: '${AZURE_API_KEY}',
+ baseURL: 'https://test.services.ai.azure.com/models',
+ version: '2024-05-01-preview',
+ serverless: true,
+ models: {
+ 'grok-3': {
+ deploymentName: 'grok-3',
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+ mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
+ mockReq.body.model = 'grok-3';
+
+ const text = 'Test Azure serverless conversation';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Verify provider was switched to OPENAI for serverless
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: Providers.OPENAI, // Should be OPENAI for serverless
+ titleMethod: 'completion',
+ titlePrompt: 'Azure serverless title prompt',
+ }),
+ );
+ });
+
+ it('should use AZURE provider for Azure endpoints with instanceName', async () => {
+ // Set up Azure endpoint
+ mockAgent.endpoint = EModelEndpoint.azureOpenAI;
+ mockAgent.provider = EModelEndpoint.azureOpenAI;
+ mockReq.config = {
+ endpoints: {
+ [EModelEndpoint.azureOpenAI]: {
+ titleConvo: true,
+ titleModel: 'gpt-4o',
+ titleMethod: 'structured',
+ titlePrompt: 'Azure instance title prompt',
+ streamRate: 35,
+ modelGroupMap: {
+ 'gpt-4o': {
+ group: 'eastus',
+ deploymentName: 'gpt-4o',
+ },
+ },
+ groupMap: {
+ eastus: {
+ apiKey: '${EASTUS_API_KEY}',
+ instanceName: 'region-instance',
+ version: '2024-02-15-preview',
+ models: {
+ 'gpt-4o': {
+ deploymentName: 'gpt-4o',
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+ mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
+ mockReq.body.model = 'gpt-4o';
+
+ const text = 'Test Azure instance conversation';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Verify provider remains AZURE with instanceName
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: Providers.AZURE,
+ titleMethod: 'structured',
+ titlePrompt: 'Azure instance title prompt',
+ }),
+ );
+ });
+
+ it('should handle Azure titleModel with CURRENT_MODEL constant', async () => {
+ // Set up Azure endpoint
+ mockAgent.endpoint = EModelEndpoint.azureOpenAI;
+ mockAgent.provider = EModelEndpoint.azureOpenAI;
+ mockAgent.model_parameters.model = 'gpt-4o-latest';
+ mockReq.config = {
+ endpoints: {
+ [EModelEndpoint.azureOpenAI]: {
+ titleConvo: true,
+ titleModel: Constants.CURRENT_MODEL,
+ titleMethod: 'functions',
+ streamRate: 35,
+ modelGroupMap: {
+ 'gpt-4o-latest': {
+ group: 'region-eastus',
+ deploymentName: 'gpt-4o-mini',
+ version: '2024-02-15-preview',
+ },
+ },
+ groupMap: {
+ 'region-eastus': {
+ apiKey: '${EASTUS2_API_KEY}',
+ instanceName: 'test-instance',
+ version: '2024-12-01-preview',
+ models: {
+ 'gpt-4o-latest': {
+ deploymentName: 'gpt-4o-mini',
+ version: '2024-02-15-preview',
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+ mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
+ mockReq.body.model = 'gpt-4o-latest';
+
+ const text = 'Test Azure current model';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Verify it uses the correct model when titleModel is CURRENT_MODEL
+ const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
+ // When CURRENT_MODEL is used with Azure, the model gets mapped to the deployment name
+ // In this case, 'gpt-4o-latest' is mapped to 'gpt-4o-mini' deployment
+ expect(generateTitleCall.clientOptions.model).toBe('gpt-4o-mini');
+ // Also verify that CURRENT_MODEL constant was not passed as the model
+ expect(generateTitleCall.clientOptions.model).not.toBe(Constants.CURRENT_MODEL);
+ });
+
+ it('should handle Azure with multiple model groups', async () => {
+ // Set up Azure endpoint
+ mockAgent.endpoint = EModelEndpoint.azureOpenAI;
+ mockAgent.provider = EModelEndpoint.azureOpenAI;
+ mockReq.config = {
+ endpoints: {
+ [EModelEndpoint.azureOpenAI]: {
+ titleConvo: true,
+ titleModel: 'o1-mini',
+ titleMethod: 'completion',
+ streamRate: 35,
+ modelGroupMap: {
+ 'gpt-4o': {
+ group: 'eastus',
+ deploymentName: 'gpt-4o',
+ },
+ 'o1-mini': {
+ group: 'region-eastus',
+ deploymentName: 'o1-mini',
+ },
+ 'codex-mini': {
+ group: 'codex-mini',
+ deploymentName: 'codex-mini',
+ },
+ },
+ groupMap: {
+ eastus: {
+ apiKey: '${EASTUS_API_KEY}',
+ instanceName: 'region-eastus',
+ version: '2024-02-15-preview',
+ models: {
+ 'gpt-4o': {
+ deploymentName: 'gpt-4o',
+ },
+ },
+ },
+ 'region-eastus': {
+ apiKey: '${EASTUS2_API_KEY}',
+ instanceName: 'region-eastus2',
+ version: '2024-12-01-preview',
+ models: {
+ 'o1-mini': {
+ deploymentName: 'o1-mini',
+ },
+ },
+ },
+ 'codex-mini': {
+ apiKey: '${AZURE_API_KEY}',
+ baseURL: 'https://example.cognitiveservices.azure.com/openai/',
+ version: '2025-04-01-preview',
+ serverless: true,
+ models: {
+ 'codex-mini': {
+ deploymentName: 'codex-mini',
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+ mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
+ mockReq.body.model = 'o1-mini';
+
+ const text = 'Test Azure multi-group conversation';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Verify correct model and provider are used
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: Providers.AZURE,
+ titleMethod: 'completion',
+ }),
+ );
+
+ const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
+ expect(generateTitleCall.clientOptions.model).toBe('o1-mini');
+ expect(generateTitleCall.clientOptions.maxTokens).toBeUndefined(); // o1 models shouldn't have maxTokens
+ });
+
+ it('should use all config as fallback for Azure endpoints', async () => {
+ // Set up Azure endpoint with minimal config
+ mockAgent.endpoint = EModelEndpoint.azureOpenAI;
+ mockAgent.provider = EModelEndpoint.azureOpenAI;
+ mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
+ mockReq.body.model = 'gpt-4';
+
+ // Set 'all' config as fallback with a serverless Azure config
+ mockReq.config = {
+ endpoints: {
+ all: {
+ titleConvo: true,
+ titleModel: 'gpt-4',
+ titleMethod: 'structured',
+ titlePrompt: 'Fallback title prompt from all config',
+ titlePromptTemplate: 'Template: {{content}}',
+ modelGroupMap: {
+ 'gpt-4': {
+ group: 'default-group',
+ deploymentName: 'gpt-4',
+ },
+ },
+ groupMap: {
+ 'default-group': {
+ apiKey: '${AZURE_API_KEY}',
+ baseURL: 'https://default.openai.azure.com/',
+ version: '2024-02-15-preview',
+ serverless: true,
+ models: {
+ 'gpt-4': {
+ deploymentName: 'gpt-4',
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+
+ const text = 'Test Azure with all config fallback';
+ const abortController = new AbortController();
+
+ await client.titleConvo({ text, abortController });
+
+ // Verify all config is used
+ expect(mockRun.generateTitle).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: Providers.OPENAI, // Should be OPENAI when no instanceName
+ titleMethod: 'structured',
+ titlePrompt: 'Fallback title prompt from all config',
+ titlePromptTemplate: 'Template: {{content}}',
+ }),
+ );
+ });
+ });
+ });
+
+ describe('getOptions method - GPT-5+ model handling', () => {
+ let mockReq;
+ let mockRes;
+ let mockAgent;
+ let mockOptions;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockAgent = {
+ id: 'agent-123',
+ endpoint: EModelEndpoint.openAI,
+ provider: EModelEndpoint.openAI,
+ model_parameters: {
+ model: 'gpt-5',
+ },
+ };
+
+ mockReq = {
+ app: {
+ locals: {},
+ },
+ user: {
+ id: 'user-123',
+ },
+ };
+
+ mockRes = {};
+
+ mockOptions = {
+ req: mockReq,
+ res: mockRes,
+ agent: mockAgent,
+ };
+
+ client = new AgentClient(mockOptions);
+ });
+
+ it('should move maxTokens to modelKwargs.max_completion_tokens for GPT-5 models', () => {
+ const clientOptions = {
+ model: 'gpt-5',
+ maxTokens: 2048,
+ temperature: 0.7,
+ };
+
+ // Simulate the getOptions logic that handles GPT-5+ models
+ if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
+ clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
+ clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
+ delete clientOptions.maxTokens;
+ }
+
+ expect(clientOptions.maxTokens).toBeUndefined();
+ expect(clientOptions.modelKwargs).toBeDefined();
+ expect(clientOptions.modelKwargs.max_completion_tokens).toBe(2048);
+ expect(clientOptions.temperature).toBe(0.7); // Other options should remain
+ });
+
+ it('should move maxTokens to modelKwargs.max_output_tokens for GPT-5 models with useResponsesApi', () => {
+ const clientOptions = {
+ model: 'gpt-5',
+ maxTokens: 2048,
+ temperature: 0.7,
+ useResponsesApi: true,
+ };
+
+ if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
+ clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
+ const paramName =
+ clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
+ clientOptions.modelKwargs[paramName] = clientOptions.maxTokens;
+ delete clientOptions.maxTokens;
+ }
+
+ expect(clientOptions.maxTokens).toBeUndefined();
+ expect(clientOptions.modelKwargs).toBeDefined();
+ expect(clientOptions.modelKwargs.max_output_tokens).toBe(2048);
+ expect(clientOptions.temperature).toBe(0.7); // Other options should remain
+ });
+
+ it('should handle GPT-5+ models with existing modelKwargs', () => {
+ const clientOptions = {
+ model: 'gpt-6',
+ maxTokens: 1500,
+ temperature: 0.8,
+ modelKwargs: {
+ customParam: 'value',
+ },
+ };
+
+ // Simulate the getOptions logic
+ if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
+ clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
+ clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
+ delete clientOptions.maxTokens;
+ }
+
+ expect(clientOptions.maxTokens).toBeUndefined();
+ expect(clientOptions.modelKwargs).toEqual({
+ customParam: 'value',
+ max_completion_tokens: 1500,
+ });
+ });
+
+ it('should not modify maxTokens for non-GPT-5+ models', () => {
+ const clientOptions = {
+ model: 'gpt-4',
+ maxTokens: 2048,
+ temperature: 0.7,
+ };
+
+ // Simulate the getOptions logic
+ if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
+ clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
+ clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
+ delete clientOptions.maxTokens;
+ }
+
+ // Should not be modified since it's GPT-4
+ expect(clientOptions.maxTokens).toBe(2048);
+ expect(clientOptions.modelKwargs).toBeUndefined();
+ });
+
+ it('should handle various GPT-5+ model formats', () => {
+ const testCases = [
+ { model: 'gpt-5.1', shouldTransform: true },
+ { model: 'gpt-5.1-chat-latest', shouldTransform: true },
+ { model: 'gpt-5.1-codex', shouldTransform: true },
+ { model: 'gpt-5', shouldTransform: true },
+ { model: 'gpt-5-turbo', shouldTransform: true },
+ { model: 'gpt-6', shouldTransform: true },
+ { model: 'gpt-7-preview', shouldTransform: true },
+ { model: 'gpt-8', shouldTransform: true },
+ { model: 'gpt-9-mini', shouldTransform: true },
+ { model: 'gpt-4', shouldTransform: false },
+ { model: 'gpt-4o', shouldTransform: false },
+ { model: 'gpt-3.5-turbo', shouldTransform: false },
+ { model: 'claude-3', shouldTransform: false },
+ ];
+
+ testCases.forEach(({ model, shouldTransform }) => {
+ const clientOptions = {
+ model,
+ maxTokens: 1000,
+ };
+
+ // Simulate the getOptions logic
+ if (
+ /\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
+ clientOptions.maxTokens != null
+ ) {
+ clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
+ clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
+ delete clientOptions.maxTokens;
+ }
+
+ if (shouldTransform) {
+ expect(clientOptions.maxTokens).toBeUndefined();
+ expect(clientOptions.modelKwargs?.max_completion_tokens).toBe(1000);
+ } else {
+ expect(clientOptions.maxTokens).toBe(1000);
+ expect(clientOptions.modelKwargs).toBeUndefined();
+ }
+ });
+ });
+
+ it('should not swap max token param for older models when using useResponsesApi', () => {
+ const testCases = [
+ { model: 'gpt-5.1', shouldTransform: true },
+ { model: 'gpt-5.1-chat-latest', shouldTransform: true },
+ { model: 'gpt-5.1-codex', shouldTransform: true },
+ { model: 'gpt-5', shouldTransform: true },
+ { model: 'gpt-5-turbo', shouldTransform: true },
+ { model: 'gpt-6', shouldTransform: true },
+ { model: 'gpt-7-preview', shouldTransform: true },
+ { model: 'gpt-8', shouldTransform: true },
+ { model: 'gpt-9-mini', shouldTransform: true },
+ { model: 'gpt-4', shouldTransform: false },
+ { model: 'gpt-4o', shouldTransform: false },
+ { model: 'gpt-3.5-turbo', shouldTransform: false },
+ { model: 'claude-3', shouldTransform: false },
+ ];
+
+ testCases.forEach(({ model, shouldTransform }) => {
+ const clientOptions = {
+ model,
+ maxTokens: 1000,
+ useResponsesApi: true,
+ };
+
+ if (
+ /\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
+ clientOptions.maxTokens != null
+ ) {
+ clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
+ const paramName =
+ clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
+ clientOptions.modelKwargs[paramName] = clientOptions.maxTokens;
+ delete clientOptions.maxTokens;
+ }
+
+ if (shouldTransform) {
+ expect(clientOptions.maxTokens).toBeUndefined();
+ expect(clientOptions.modelKwargs?.max_output_tokens).toBe(1000);
+ } else {
+ expect(clientOptions.maxTokens).toBe(1000);
+ expect(clientOptions.modelKwargs).toBeUndefined();
+ }
+ });
+ });
+
+ it('should not transform if maxTokens is null or undefined', () => {
+ const testCases = [
+ { model: 'gpt-5', maxTokens: null },
+ { model: 'gpt-5', maxTokens: undefined },
+ { model: 'gpt-6', maxTokens: 0 }, // Should transform even if 0
+ ];
+
+ testCases.forEach(({ model, maxTokens }, index) => {
+ const clientOptions = {
+ model,
+ maxTokens,
+ temperature: 0.7,
+ };
+
+ // Simulate the getOptions logic
+ if (
+ /\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
+ clientOptions.maxTokens != null
+ ) {
+ clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
+ clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
+ delete clientOptions.maxTokens;
+ }
+
+ if (index < 2) {
+ // null or undefined cases
+ expect(clientOptions.maxTokens).toBe(maxTokens);
+ expect(clientOptions.modelKwargs).toBeUndefined();
+ } else {
+ // 0 case - should transform
+ expect(clientOptions.maxTokens).toBeUndefined();
+ expect(clientOptions.modelKwargs?.max_completion_tokens).toBe(0);
+ }
+ });
+ });
+ });
+
+ describe('buildMessages with MCP server instructions', () => {
+ let client;
+ let mockReq;
+ let mockRes;
+ let mockAgent;
+ let mockOptions;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Reset the mock to default behavior
+ mockFormatInstructions.mockResolvedValue(
+ '# MCP Server Instructions\n\nTest MCP instructions here',
+ );
+
+ const { DynamicStructuredTool } = require('@langchain/core/tools');
+
+ // Create mock MCP tools with the delimiter pattern
+ const mockMCPTool1 = new DynamicStructuredTool({
+ name: `tool1${Constants.mcp_delimiter}server1`,
+ description: 'Test MCP tool 1',
+ schema: {},
+ func: async () => 'result',
+ });
+
+ const mockMCPTool2 = new DynamicStructuredTool({
+ name: `tool2${Constants.mcp_delimiter}server2`,
+ description: 'Test MCP tool 2',
+ schema: {},
+ func: async () => 'result',
+ });
+
+ mockAgent = {
+ id: 'agent-123',
+ endpoint: EModelEndpoint.openAI,
+ provider: EModelEndpoint.openAI,
+ instructions: 'Base agent instructions',
+ model_parameters: {
+ model: 'gpt-4',
+ },
+ tools: [mockMCPTool1, mockMCPTool2],
+ };
+
+ mockReq = {
+ user: {
+ id: 'user-123',
+ },
+ body: {
+ endpoint: EModelEndpoint.openAI,
+ },
+ config: {},
+ };
+
+ mockRes = {};
+
+ mockOptions = {
+ req: mockReq,
+ res: mockRes,
+ agent: mockAgent,
+ endpoint: EModelEndpoint.agents,
+ };
+
+ client = new AgentClient(mockOptions);
+ client.conversationId = 'convo-123';
+ client.responseMessageId = 'response-123';
+ client.shouldSummarize = false;
+ client.maxContextTokens = 4096;
+ });
+
+ it('should await MCP instructions and not include [object Promise] in agent instructions', async () => {
+ // Set specific return value for this test
+ mockFormatInstructions.mockResolvedValue(
+ '# MCP Server Instructions\n\nUse these tools carefully',
+ );
+
+ const messages = [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'Hello',
+ isCreatedByUser: true,
+ },
+ ];
+
+ await client.buildMessages(messages, null, {
+ instructions: 'Base instructions',
+ additional_instructions: null,
+ });
+
+ // Verify formatInstructionsForContext was called with correct server names
+ expect(mockFormatInstructions).toHaveBeenCalledWith(['server1', 'server2']);
+
+ // Verify the instructions do NOT contain [object Promise]
+ expect(client.options.agent.instructions).not.toContain('[object Promise]');
+
+ // Verify the instructions DO contain the MCP instructions
+ expect(client.options.agent.instructions).toContain('# MCP Server Instructions');
+ expect(client.options.agent.instructions).toContain('Use these tools carefully');
+
+ // Verify the base instructions are also included
+ expect(client.options.agent.instructions).toContain('Base instructions');
+ });
+
+ it('should handle MCP instructions with ephemeral agent', async () => {
+ // Set specific return value for this test
+ mockFormatInstructions.mockResolvedValue(
+ '# Ephemeral MCP Instructions\n\nSpecial ephemeral instructions',
+ );
+
+ // Set up ephemeral agent with MCP servers
+ mockReq.body.ephemeralAgent = {
+ mcp: ['ephemeral-server1', 'ephemeral-server2'],
+ };
+
+ const messages = [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'Test ephemeral',
+ isCreatedByUser: true,
+ },
+ ];
+
+ await client.buildMessages(messages, null, {
+ instructions: 'Ephemeral instructions',
+ additional_instructions: null,
+ });
+
+ // Verify formatInstructionsForContext was called with ephemeral server names
+ expect(mockFormatInstructions).toHaveBeenCalledWith([
+ 'ephemeral-server1',
+ 'ephemeral-server2',
+ ]);
+
+ // Verify no [object Promise] in instructions
+ expect(client.options.agent.instructions).not.toContain('[object Promise]');
+
+ // Verify ephemeral MCP instructions are included
+ expect(client.options.agent.instructions).toContain('# Ephemeral MCP Instructions');
+ expect(client.options.agent.instructions).toContain('Special ephemeral instructions');
+ });
+
+ it('should handle empty MCP instructions gracefully', async () => {
+ // Set empty return value for this test
+ mockFormatInstructions.mockResolvedValue('');
+
+ const messages = [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'Hello',
+ isCreatedByUser: true,
+ },
+ ];
+
+ await client.buildMessages(messages, null, {
+ instructions: 'Base instructions only',
+ additional_instructions: null,
+ });
+
+ // Verify the instructions still work without MCP content
+ expect(client.options.agent.instructions).toBe('Base instructions only');
+ expect(client.options.agent.instructions).not.toContain('[object Promise]');
+ });
+
+ it('should handle MCP instructions error gracefully', async () => {
+ // Set error return for this test
+ mockFormatInstructions.mockRejectedValue(new Error('MCP error'));
+
+ const messages = [
+ {
+ messageId: 'msg-1',
+ parentMessageId: null,
+ sender: 'User',
+ text: 'Hello',
+ isCreatedByUser: true,
+ },
+ ];
+
+ // Should not throw
+ await client.buildMessages(messages, null, {
+ instructions: 'Base instructions',
+ additional_instructions: null,
+ });
+
+ // Should still have base instructions without MCP content
+ expect(client.options.agent.instructions).toContain('Base instructions');
+ expect(client.options.agent.instructions).not.toContain('[object Promise]');
+ });
+ });
+
+ describe('runMemory method', () => {
+ let client;
+ let mockReq;
+ let mockRes;
+ let mockAgent;
+ let mockOptions;
+ let mockProcessMemory;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockAgent = {
+ id: 'agent-123',
+ endpoint: EModelEndpoint.openAI,
+ provider: EModelEndpoint.openAI,
+ model_parameters: {
+ model: 'gpt-4',
+ },
+ };
+
+ mockReq = {
+ user: {
+ id: 'user-123',
+ personalization: {
+ memories: true,
+ },
+ },
+ };
+
+ // Mock getAppConfig for memory tests
+ mockReq.config = {
+ memory: {
+ messageWindowSize: 3,
+ },
+ };
+
+ mockRes = {};
+
+ mockOptions = {
+ req: mockReq,
+ res: mockRes,
+ agent: mockAgent,
+ };
+
+ mockProcessMemory = jest.fn().mockResolvedValue([]);
+
+ client = new AgentClient(mockOptions);
+ client.processMemory = mockProcessMemory;
+ client.conversationId = 'convo-123';
+ client.responseMessageId = 'response-123';
+ });
+
+ it('should filter out image URLs from message content', async () => {
+ const { HumanMessage, AIMessage } = require('@langchain/core/messages');
+ const messages = [
+ new HumanMessage({
+ content: [
+ {
+ type: 'text',
+ text: 'What is in this image?',
+ },
+ {
+ type: 'image_url',
+ image_url: {
+ url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
+ detail: 'auto',
+ },
+ },
+ ],
+ }),
+ new AIMessage('I can see a small red pixel in the image.'),
+ new HumanMessage({
+ content: [
+ {
+ type: 'text',
+ text: 'What about this one?',
+ },
+ {
+ type: 'image_url',
+ image_url: {
+ url: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/',
+ detail: 'high',
+ },
+ },
+ ],
+ }),
+ ];
+
+ await client.runMemory(messages);
+
+ expect(mockProcessMemory).toHaveBeenCalledTimes(1);
+ const processedMessage = mockProcessMemory.mock.calls[0][0][0];
+
+ // Verify the buffer message was created
+ expect(processedMessage.constructor.name).toBe('HumanMessage');
+ expect(processedMessage.content).toContain('# Current Chat:');
+
+ // Verify that image URLs are not in the buffer string
+ expect(processedMessage.content).not.toContain('image_url');
+ expect(processedMessage.content).not.toContain('data:image');
+ expect(processedMessage.content).not.toContain('base64');
+
+ // Verify text content is preserved
+ expect(processedMessage.content).toContain('What is in this image?');
+ expect(processedMessage.content).toContain('I can see a small red pixel in the image.');
+ expect(processedMessage.content).toContain('What about this one?');
+ });
+
+ it('should handle messages with only text content', async () => {
+ const { HumanMessage, AIMessage } = require('@langchain/core/messages');
+ const messages = [
+ new HumanMessage('Hello, how are you?'),
+ new AIMessage('I am doing well, thank you!'),
+ new HumanMessage('That is great to hear.'),
+ ];
+
+ await client.runMemory(messages);
+
+ expect(mockProcessMemory).toHaveBeenCalledTimes(1);
+ const processedMessage = mockProcessMemory.mock.calls[0][0][0];
+
+ expect(processedMessage.content).toContain('Hello, how are you?');
+ expect(processedMessage.content).toContain('I am doing well, thank you!');
+ expect(processedMessage.content).toContain('That is great to hear.');
+ });
+
+ it('should handle mixed content types correctly', async () => {
+ const { HumanMessage } = require('@langchain/core/messages');
+ const { ContentTypes } = require('librechat-data-provider');
+
+ const messages = [
+ new HumanMessage({
+ content: [
+ {
+ type: 'text',
+ text: 'Here is some text',
+ },
+ {
+ type: ContentTypes.IMAGE_URL,
+ image_url: {
+ url: 'https://example.com/image.png',
+ },
+ },
+ {
+ type: 'text',
+ text: ' and more text',
+ },
+ ],
+ }),
+ ];
+
+ await client.runMemory(messages);
+
+ expect(mockProcessMemory).toHaveBeenCalledTimes(1);
+ const processedMessage = mockProcessMemory.mock.calls[0][0][0];
+
+ // Should contain text parts but not image URLs
+ expect(processedMessage.content).toContain('Here is some text');
+ expect(processedMessage.content).toContain('and more text');
+ expect(processedMessage.content).not.toContain('example.com/image.png');
+ expect(processedMessage.content).not.toContain('IMAGE_URL');
+ });
+
+ it('should preserve original messages without mutation', async () => {
+ const { HumanMessage } = require('@langchain/core/messages');
+ const originalContent = [
+ {
+ type: 'text',
+ text: 'Original text',
+ },
+ {
+ type: 'image_url',
+ image_url: {
+ url: 'data:image/png;base64,ABC123',
+ },
+ },
+ ];
+
+ const messages = [
+ new HumanMessage({
+ content: [...originalContent],
+ }),
+ ];
+
+ await client.runMemory(messages);
+
+ // Verify original message wasn't mutated
+ expect(messages[0].content).toHaveLength(2);
+ expect(messages[0].content[1].type).toBe('image_url');
+ expect(messages[0].content[1].image_url.url).toBe('data:image/png;base64,ABC123');
+ });
+
+ it('should handle message window size correctly', async () => {
+ const { HumanMessage, AIMessage } = require('@langchain/core/messages');
+ const messages = [
+ new HumanMessage('Message 1'),
+ new AIMessage('Response 1'),
+ new HumanMessage('Message 2'),
+ new AIMessage('Response 2'),
+ new HumanMessage('Message 3'),
+ new AIMessage('Response 3'),
+ ];
+
+ // Window size is set to 3 in mockReq
+ await client.runMemory(messages);
+
+ expect(mockProcessMemory).toHaveBeenCalledTimes(1);
+ const processedMessage = mockProcessMemory.mock.calls[0][0][0];
+
+ // Should only include last 3 messages due to window size
+ expect(processedMessage.content).toContain('Message 3');
+ expect(processedMessage.content).toContain('Response 3');
+ expect(processedMessage.content).not.toContain('Message 1');
+ expect(processedMessage.content).not.toContain('Response 1');
+ });
+
+ it('should return early if processMemory is not set', async () => {
+ const { HumanMessage } = require('@langchain/core/messages');
+ client.processMemory = null;
+
+ const result = await client.runMemory([new HumanMessage('Test')]);
+
+ expect(result).toBeUndefined();
+ expect(mockProcessMemory).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/api/server/controllers/agents/errors.js b/api/server/controllers/agents/errors.js
new file mode 100644
index 0000000000..54b296a5d2
--- /dev/null
+++ b/api/server/controllers/agents/errors.js
@@ -0,0 +1,141 @@
+// errorHandler.js
+const { logger } = require('@librechat/data-schemas');
+const { CacheKeys, ViolationTypes } = require('librechat-data-provider');
+const { sendResponse } = require('~/server/middleware/error');
+const { recordUsage } = require('~/server/services/Threads');
+const { getConvo } = require('~/models/Conversation');
+const getLogStores = require('~/cache/getLogStores');
+
+/**
+ * @typedef {Object} ErrorHandlerContext
+ * @property {OpenAIClient} openai - The OpenAI client
+ * @property {string} run_id - The run ID
+ * @property {boolean} completedRun - Whether the run has completed
+ * @property {string} assistant_id - The assistant ID
+ * @property {string} conversationId - The conversation ID
+ * @property {string} parentMessageId - The parent message ID
+ * @property {string} responseMessageId - The response message ID
+ * @property {string} endpoint - The endpoint being used
+ * @property {string} cacheKey - The cache key for the current request
+ */
+
+/**
+ * @typedef {Object} ErrorHandlerDependencies
+ * @property {ServerRequest} req - The Express request object
+ * @property {Express.Response} res - The Express response object
+ * @property {() => ErrorHandlerContext} getContext - Function to get the current context
+ * @property {string} [originPath] - The origin path for the error handler
+ */
+
+/**
+ * Creates an error handler function with the given dependencies
+ * @param {ErrorHandlerDependencies} dependencies - The dependencies for the error handler
+ * @returns {(error: Error) => Promise} The error handler function
+ */
+const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/chat/' }) => {
+ const cache = getLogStores(CacheKeys.ABORT_KEYS);
+
+ /**
+ * Handles errors that occur during the chat process
+ * @param {Error} error - The error that occurred
+ * @returns {Promise}
+ */
+ return async (error) => {
+ const {
+ openai,
+ run_id,
+ endpoint,
+ cacheKey,
+ completedRun,
+ assistant_id,
+ conversationId,
+ parentMessageId,
+ responseMessageId,
+ } = getContext();
+
+ const defaultErrorMessage =
+ 'The Assistant run failed to initialize. Try sending a message in a new conversation.';
+ const messageData = {
+ assistant_id,
+ conversationId,
+ parentMessageId,
+ sender: 'System',
+ user: req.user.id,
+ shouldSaveMessage: false,
+ messageId: responseMessageId,
+ endpoint,
+ };
+
+ if (error.message === 'Run cancelled') {
+ return res.end();
+ } else if (error.message === 'Request closed' && completedRun) {
+ return;
+ } else if (error.message === 'Request closed') {
+ logger.debug(`[${originPath}] Request aborted on close`);
+ } else if (/Files.*are invalid/.test(error.message)) {
+ const errorMessage = `Files are invalid, or may not have uploaded yet.${
+ endpoint === 'azureAssistants'
+ ? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
+ : ''
+ }`;
+ return sendResponse(req, res, messageData, errorMessage);
+ } else if (error?.message?.includes('string too long')) {
+ return sendResponse(
+ req,
+ res,
+ messageData,
+ 'Message too long. The Assistants API has a limit of 32,768 characters per message. Please shorten it and try again.',
+ );
+ } else if (error?.message?.includes(ViolationTypes.TOKEN_BALANCE)) {
+ return sendResponse(req, res, messageData, error.message);
+ } else {
+ logger.error(`[${originPath}]`, error);
+ }
+
+ if (!openai || !run_id) {
+ return sendResponse(req, res, messageData, defaultErrorMessage);
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ try {
+ const status = await cache.get(cacheKey);
+ if (status === 'cancelled') {
+ logger.debug(`[${originPath}] Run already cancelled`);
+ return res.end();
+ }
+ await cache.delete(cacheKey);
+ } catch (error) {
+ logger.error(`[${originPath}] Error cancelling run`, error);
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ let run;
+ try {
+ await recordUsage({
+ ...run.usage,
+ model: run.model,
+ user: req.user.id,
+ conversationId,
+ });
+ } catch (error) {
+ logger.error(`[${originPath}] Error fetching or processing run`, error);
+ }
+
+ let finalEvent;
+ try {
+ finalEvent = {
+ final: true,
+ conversation: await getConvo(req.user.id, conversationId),
+ };
+ } catch (error) {
+ logger.error(`[${originPath}] Error finalizing error process`, error);
+ return sendResponse(req, res, messageData, 'The Assistant run failed');
+ }
+
+ return sendResponse(req, res, finalEvent);
+ };
+};
+
+module.exports = { createErrorHandler };
diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js
new file mode 100644
index 0000000000..faf3905349
--- /dev/null
+++ b/api/server/controllers/agents/request.js
@@ -0,0 +1,332 @@
+const { logger } = require('@librechat/data-schemas');
+const { Constants } = require('librechat-data-provider');
+const {
+ sendEvent,
+ sanitizeFileForTransmit,
+ sanitizeMessageForTransmit,
+} = require('@librechat/api');
+const {
+ handleAbortError,
+ createAbortController,
+ cleanupAbortController,
+} = require('~/server/middleware');
+const { disposeClient, clientRegistry, requestDataMap } = require('~/server/cleanup');
+const { saveMessage } = require('~/models');
+
+function createCloseHandler(abortController) {
+ return function (manual) {
+ if (!manual) {
+ logger.debug('[AgentController] Request closed');
+ }
+ if (!abortController) {
+ return;
+ } else if (abortController.signal.aborted) {
+ return;
+ } else if (abortController.requestCompleted) {
+ return;
+ }
+
+ abortController.abort();
+ logger.debug('[AgentController] Request aborted on close');
+ };
+}
+
+const AgentController = async (req, res, next, initializeClient, addTitle) => {
+ let {
+ text,
+ isRegenerate,
+ endpointOption,
+ conversationId,
+ isContinued = false,
+ editedContent = null,
+ parentMessageId = null,
+ overrideParentMessageId = null,
+ responseMessageId: editedResponseMessageId = null,
+ } = req.body;
+
+ let sender;
+ let abortKey;
+ let userMessage;
+ let promptTokens;
+ let userMessageId;
+ let responseMessageId;
+ let userMessagePromise;
+ let getAbortData;
+ let client = null;
+ let cleanupHandlers = [];
+
+ const newConvo = !conversationId;
+ const userId = req.user.id;
+
+ // Create handler to avoid capturing the entire parent scope
+ let 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 (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');
+ if (Array.isArray(cleanupHandlers)) {
+ for (const handler of cleanupHandlers) {
+ try {
+ if (typeof handler === 'function') {
+ handler();
+ }
+ } catch (e) {
+ logger.error('[AgentController] Error in cleanup handler', e);
+ }
+ }
+ }
+
+ // 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 {
+ let prelimAbortController = new AbortController();
+ const prelimCloseHandler = createCloseHandler(prelimAbortController);
+ res.on('close', prelimCloseHandler);
+ const removePrelimHandler = (manual) => {
+ try {
+ prelimCloseHandler(manual);
+ res.removeListener('close', prelimCloseHandler);
+ } catch (e) {
+ logger.error('[AgentController] Error removing close listener', e);
+ }
+ };
+ cleanupHandlers.push(removePrelimHandler);
+ /** @type {{ client: TAgentClient; userMCPAuthMap?: Record> }} */
+ const result = await initializeClient({
+ req,
+ res,
+ endpointOption,
+ signal: prelimAbortController.signal,
+ });
+ if (prelimAbortController.signal?.aborted) {
+ prelimAbortController = null;
+ throw new Error('Request was aborted before initialization could complete');
+ } else {
+ prelimAbortController = null;
+ removePrelimHandler(true);
+ cleanupHandlers.pop();
+ }
+ client = result.client;
+
+ // 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);
+ const closeHandler = createCloseHandler(abortController);
+ res.on('close', closeHandler);
+ cleanupHandlers.push(() => {
+ try {
+ res.removeListener('close', closeHandler);
+ } catch (e) {
+ logger.error('[AgentController] Error removing close listener', e);
+ }
+ });
+
+ const messageOptions = {
+ user: userId,
+ onStart,
+ getReqData,
+ isContinued,
+ isRegenerate,
+ editedContent,
+ conversationId,
+ parentMessageId,
+ abortController,
+ overrideParentMessageId,
+ isEdited: !!editedContent,
+ userMCPAuthMap: result.userMCPAuthMap,
+ responseMessageId: editedResponseMessageId,
+ progressOptions: {
+ res,
+ },
+ };
+
+ let response = await client.sendMessage(text, messageOptions);
+
+ // 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';
+
+ // Process files if needed (sanitize to remove large text fields before transmission)
+ if (req.body.files && client.options?.attachments) {
+ userMessage.files = [];
+ const messageFiles = new Set(req.body.files.map((file) => file.file_id));
+ for (const attachment of client.options.attachments) {
+ if (messageFiles.has(attachment.file_id)) {
+ userMessage.files.push(sanitizeFileForTransmit(attachment));
+ }
+ }
+ delete userMessage.image_urls;
+ }
+
+ // Only send if not aborted
+ if (!abortController.signal.aborted) {
+ // Create a new response object with minimal copies
+ const finalResponse = { ...response };
+
+ sendEvent(res, {
+ final: true,
+ conversation,
+ title: conversation.title,
+ requestMessage: sanitizeMessageForTransmit(userMessage),
+ responseMessage: finalResponse,
+ });
+ res.end();
+
+ // Save the message if needed
+ if (client.savedMessageIds && !client.savedMessageIds.has(messageId)) {
+ await saveMessage(
+ req,
+ { ...finalResponse, user: userId },
+ { context: 'api/server/controllers/agents/request.js - response end' },
+ );
+ }
+ }
+ // Edge case: sendMessage completed but abort happened during sendCompletion
+ // We need to ensure a final event is sent
+ else if (!res.headersSent && !res.finished) {
+ logger.debug(
+ '[AgentController] Handling edge case: `sendMessage` completed but aborted during `sendCompletion`',
+ );
+
+ const finalResponse = { ...response };
+ finalResponse.error = true;
+
+ sendEvent(res, {
+ final: true,
+ conversation,
+ title: conversation.title,
+ requestMessage: sanitizeMessageForTransmit(userMessage),
+ responseMessage: finalResponse,
+ error: { message: 'Request was aborted during completion' },
+ });
+ res.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 },
+ 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,
+ userMessageId,
+ })
+ .catch((err) => {
+ logger.error('[api/server/controllers/agents/request] Error in `handleAbortError`', err);
+ })
+ .finally(() => {
+ performCleanup();
+ });
+ }
+};
+
+module.exports = AgentController;
diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js
new file mode 100644
index 0000000000..01e33f913b
--- /dev/null
+++ b/api/server/controllers/agents/v1.js
@@ -0,0 +1,770 @@
+const { z } = require('zod');
+const fs = require('fs').promises;
+const { nanoid } = require('nanoid');
+const { logger } = require('@librechat/data-schemas');
+const {
+ agentCreateSchema,
+ agentUpdateSchema,
+ mergeAgentOcrConversion,
+ convertOcrToContextInPlace,
+} = require('@librechat/api');
+const {
+ Tools,
+ Constants,
+ FileSources,
+ ResourceType,
+ AccessRoleIds,
+ PrincipalType,
+ EToolResources,
+ PermissionBits,
+ actionDelimiter,
+ removeNullishValues,
+ CacheKeys,
+ Time,
+} = require('librechat-data-provider');
+const {
+ getListAgentsByAccess,
+ countPromotedAgents,
+ revertAgentVersion,
+ createAgent,
+ updateAgent,
+ deleteAgent,
+ getAgent,
+} = require('~/models/Agent');
+const {
+ findPubliclyAccessibleResources,
+ findAccessibleResources,
+ hasPublicPermission,
+ grantPermission,
+} = require('~/server/services/PermissionService');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+const { getCategoriesWithCounts, deleteFileByFilter } = require('~/models');
+const { resizeAvatar } = require('~/server/services/Files/images/avatar');
+const { getFileStrategy } = require('~/server/utils/getFileStrategy');
+const { refreshS3Url } = require('~/server/services/Files/S3/crud');
+const { filterFile } = require('~/server/services/Files/process');
+const { updateAction, getActions } = require('~/models/Action');
+const { getCachedTools } = require('~/server/services/Config');
+const { getLogStores } = require('~/cache');
+
+const systemTools = {
+ [Tools.execute_code]: true,
+ [Tools.file_search]: true,
+ [Tools.web_search]: true,
+};
+
+const MAX_SEARCH_LEN = 100;
+const escapeRegex = (str = '') => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+/**
+ * Opportunistically refreshes S3-backed avatars for agent list responses.
+ * Only list responses are refreshed because they're the highest-traffic surface and
+ * the avatar URLs have a short-lived TTL. The refresh is cached per-user for 30 minutes
+ * via {@link CacheKeys.S3_EXPIRY_INTERVAL} so we refresh once per interval at most.
+ * @param {Array} agents - Agents being enriched with S3-backed avatars
+ * @param {string} userId - User identifier used for the cache refresh key
+ */
+const refreshListAvatars = async (agents, userId) => {
+ if (!agents?.length) {
+ return;
+ }
+
+ const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
+ const refreshKey = `${userId}:agents_list`;
+ const alreadyChecked = await cache.get(refreshKey);
+ if (alreadyChecked) {
+ return;
+ }
+
+ await Promise.all(
+ agents.map(async (agent) => {
+ if (agent?.avatar?.source !== FileSources.s3 || !agent?.avatar?.filepath) {
+ return;
+ }
+
+ try {
+ const newPath = await refreshS3Url(agent.avatar);
+ if (newPath && newPath !== agent.avatar.filepath) {
+ agent.avatar = { ...agent.avatar, filepath: newPath };
+ }
+ } catch (err) {
+ logger.debug('[/Agents] Avatar refresh error for list item', err);
+ }
+ }),
+ );
+
+ await cache.set(refreshKey, true, Time.THIRTY_MINUTES);
+};
+
+/**
+ * Creates an Agent.
+ * @route POST /Agents
+ * @param {ServerRequest} req - The request object.
+ * @param {AgentCreateParams} req.body - The request body.
+ * @param {ServerResponse} res - The response object.
+ * @returns {Promise} 201 - success response - application/json
+ */
+const createAgentHandler = async (req, res) => {
+ try {
+ const validatedData = agentCreateSchema.parse(req.body);
+ const { tools = [], ...agentData } = removeNullishValues(validatedData);
+
+ const { id: userId } = req.user;
+
+ agentData.id = `agent_${nanoid()}`;
+ agentData.author = userId;
+ agentData.tools = [];
+
+ const availableTools = await getCachedTools();
+ for (const tool of tools) {
+ if (availableTools[tool]) {
+ agentData.tools.push(tool);
+ } else if (systemTools[tool]) {
+ agentData.tools.push(tool);
+ } else if (tool.includes(Constants.mcp_delimiter)) {
+ agentData.tools.push(tool);
+ }
+ }
+
+ const agent = await createAgent(agentData);
+
+ // Automatically grant owner permissions to the creator
+ try {
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: userId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_OWNER,
+ grantedBy: userId,
+ });
+ logger.debug(
+ `[createAgent] Granted owner permissions to user ${userId} for agent ${agent.id}`,
+ );
+ } catch (permissionError) {
+ logger.error(
+ `[createAgent] Failed to grant owner permissions for agent ${agent.id}:`,
+ permissionError,
+ );
+ }
+
+ res.status(201).json(agent);
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ logger.error('[/Agents] Validation error', error.errors);
+ return res.status(400).json({ error: 'Invalid request data', details: error.errors });
+ }
+ logger.error('[/Agents] Error creating agent', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Retrieves an Agent by ID.
+ * @route GET /Agents/:id
+ * @param {object} req - Express Request
+ * @param {object} req.params - Request params
+ * @param {string} req.params.id - Agent identifier.
+ * @param {object} req.user - Authenticated user information
+ * @param {string} req.user.id - User ID
+ * @returns {Promise} 200 - success response - application/json
+ * @returns {Error} 404 - Agent not found
+ */
+const getAgentHandler = async (req, res, expandProperties = false) => {
+ try {
+ const id = req.params.id;
+ const author = req.user.id;
+
+ // Permissions are validated by middleware before calling this function
+ // Simply load the agent by ID
+ const agent = await getAgent({ id });
+
+ if (!agent) {
+ return res.status(404).json({ error: 'Agent not found' });
+ }
+
+ agent.version = agent.versions ? agent.versions.length : 0;
+
+ if (agent.avatar && agent.avatar?.source === FileSources.s3) {
+ try {
+ agent.avatar = {
+ ...agent.avatar,
+ filepath: await refreshS3Url(agent.avatar),
+ };
+ } catch (e) {
+ logger.warn('[/Agents/:id] Failed to refresh S3 URL', e);
+ }
+ }
+
+ agent.author = agent.author.toString();
+
+ // @deprecated - isCollaborative replaced by ACL permissions
+ agent.isCollaborative = !!agent.isCollaborative;
+
+ // Check if agent is public
+ const isPublic = await hasPublicPermission({
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ requiredPermissions: PermissionBits.VIEW,
+ });
+ agent.isPublic = isPublic;
+
+ if (agent.author !== author) {
+ delete agent.author;
+ }
+
+ if (!expandProperties) {
+ // VIEW permission: Basic agent info only
+ return res.status(200).json({
+ _id: agent._id,
+ id: agent.id,
+ name: agent.name,
+ description: agent.description,
+ avatar: agent.avatar,
+ author: agent.author,
+ provider: agent.provider,
+ model: agent.model,
+ projectIds: agent.projectIds,
+ // @deprecated - isCollaborative replaced by ACL permissions
+ isCollaborative: agent.isCollaborative,
+ isPublic: agent.isPublic,
+ version: agent.version,
+ // Safe metadata
+ createdAt: agent.createdAt,
+ updatedAt: agent.updatedAt,
+ });
+ }
+
+ // EDIT permission: Full agent details including sensitive configuration
+ return res.status(200).json(agent);
+ } catch (error) {
+ logger.error('[/Agents/:id] Error retrieving agent', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Updates an Agent.
+ * @route PATCH /Agents/:id
+ * @param {object} req - Express Request
+ * @param {object} req.params - Request params
+ * @param {string} req.params.id - Agent identifier.
+ * @param {AgentUpdateParams} req.body - The Agent update parameters.
+ * @returns {Promise} 200 - success response - application/json
+ */
+const updateAgentHandler = async (req, res) => {
+ try {
+ const id = req.params.id;
+ const validatedData = agentUpdateSchema.parse(req.body);
+ // Preserve explicit null for avatar to allow resetting the avatar
+ const { avatar: avatarField, _id, ...rest } = validatedData;
+ const updateData = removeNullishValues(rest);
+ if (avatarField === null) {
+ updateData.avatar = avatarField;
+ }
+
+ // Convert OCR to context in incoming updateData
+ convertOcrToContextInPlace(updateData);
+
+ const existingAgent = await getAgent({ id });
+
+ if (!existingAgent) {
+ return res.status(404).json({ error: 'Agent not found' });
+ }
+
+ // Convert legacy OCR tool resource to context format in existing agent
+ const ocrConversion = mergeAgentOcrConversion(existingAgent, updateData);
+ if (ocrConversion.tool_resources) {
+ updateData.tool_resources = ocrConversion.tool_resources;
+ }
+ if (ocrConversion.tools) {
+ updateData.tools = ocrConversion.tools;
+ }
+
+ let updatedAgent =
+ Object.keys(updateData).length > 0
+ ? await updateAgent({ id }, updateData, {
+ updatingUserId: req.user.id,
+ })
+ : existingAgent;
+
+ // Add version count to the response
+ updatedAgent.version = updatedAgent.versions ? updatedAgent.versions.length : 0;
+
+ if (updatedAgent.author) {
+ updatedAgent.author = updatedAgent.author.toString();
+ }
+
+ if (updatedAgent.author !== req.user.id) {
+ delete updatedAgent.author;
+ }
+
+ return res.json(updatedAgent);
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ logger.error('[/Agents/:id] Validation error', error.errors);
+ return res.status(400).json({ error: 'Invalid request data', details: error.errors });
+ }
+
+ logger.error('[/Agents/:id] Error updating Agent', error);
+
+ if (error.statusCode === 409) {
+ return res.status(409).json({
+ error: error.message,
+ details: error.details,
+ });
+ }
+
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Duplicates an Agent based on the provided ID.
+ * @route POST /Agents/:id/duplicate
+ * @param {object} req - Express Request
+ * @param {object} req.params - Request params
+ * @param {string} req.params.id - Agent identifier.
+ * @returns {Promise} 201 - success response - application/json
+ */
+const duplicateAgentHandler = async (req, res) => {
+ const { id } = req.params;
+ const { id: userId } = req.user;
+ const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
+
+ try {
+ const agent = await getAgent({ id });
+ if (!agent) {
+ return res.status(404).json({
+ error: 'Agent not found',
+ status: 'error',
+ });
+ }
+
+ const {
+ id: _id,
+ _id: __id,
+ author: _author,
+ createdAt: _createdAt,
+ updatedAt: _updatedAt,
+ tool_resources: _tool_resources = {},
+ versions: _versions,
+ __v: _v,
+ ...cloneData
+ } = agent;
+ cloneData.name = `${agent.name} (${new Date().toLocaleString('en-US', {
+ dateStyle: 'short',
+ timeStyle: 'short',
+ hour12: false,
+ })})`;
+
+ if (_tool_resources?.[EToolResources.context]) {
+ cloneData.tool_resources = {
+ [EToolResources.context]: _tool_resources[EToolResources.context],
+ };
+ }
+
+ if (_tool_resources?.[EToolResources.ocr]) {
+ cloneData.tool_resources = {
+ /** Legacy conversion from `ocr` to `context` */
+ [EToolResources.context]: {
+ ...(_tool_resources[EToolResources.context] ?? {}),
+ ..._tool_resources[EToolResources.ocr],
+ },
+ };
+ }
+
+ const newAgentId = `agent_${nanoid()}`;
+ const newAgentData = Object.assign(cloneData, {
+ id: newAgentId,
+ author: userId,
+ });
+
+ const newActionsList = [];
+ const originalActions = (await getActions({ agent_id: id }, true)) ?? [];
+ const promises = [];
+
+ /**
+ * Duplicates an action and returns the new action ID.
+ * @param {Action} action
+ * @returns {Promise}
+ */
+ const duplicateAction = async (action) => {
+ const newActionId = nanoid();
+ const [domain] = action.action_id.split(actionDelimiter);
+ const fullActionId = `${domain}${actionDelimiter}${newActionId}`;
+
+ // Sanitize sensitive metadata before persisting
+ const filteredMetadata = { ...(action.metadata || {}) };
+ for (const field of sensitiveFields) {
+ delete filteredMetadata[field];
+ }
+
+ const newAction = await updateAction(
+ { action_id: newActionId },
+ {
+ metadata: filteredMetadata,
+ agent_id: newAgentId,
+ user: userId,
+ },
+ );
+
+ newActionsList.push(newAction);
+ return fullActionId;
+ };
+
+ for (const action of originalActions) {
+ promises.push(
+ duplicateAction(action).catch((error) => {
+ logger.error('[/agents/:id/duplicate] Error duplicating Action:', error);
+ }),
+ );
+ }
+
+ const agentActions = await Promise.all(promises);
+ newAgentData.actions = agentActions;
+ const newAgent = await createAgent(newAgentData);
+
+ // Automatically grant owner permissions to the duplicator
+ try {
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: userId,
+ resourceType: ResourceType.AGENT,
+ resourceId: newAgent._id,
+ accessRoleId: AccessRoleIds.AGENT_OWNER,
+ grantedBy: userId,
+ });
+ logger.debug(
+ `[duplicateAgent] Granted owner permissions to user ${userId} for duplicated agent ${newAgent.id}`,
+ );
+ } catch (permissionError) {
+ logger.error(
+ `[duplicateAgent] Failed to grant owner permissions for duplicated agent ${newAgent.id}:`,
+ permissionError,
+ );
+ }
+
+ return res.status(201).json({
+ agent: newAgent,
+ actions: newActionsList,
+ });
+ } catch (error) {
+ logger.error('[/Agents/:id/duplicate] Error duplicating Agent:', error);
+
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Deletes an Agent based on the provided ID.
+ * @route DELETE /Agents/:id
+ * @param {object} req - Express Request
+ * @param {object} req.params - Request params
+ * @param {string} req.params.id - Agent identifier.
+ * @returns {Promise} 200 - success response - application/json
+ */
+const deleteAgentHandler = async (req, res) => {
+ try {
+ const id = req.params.id;
+ const agent = await getAgent({ id });
+ if (!agent) {
+ return res.status(404).json({ error: 'Agent not found' });
+ }
+ await deleteAgent({ id });
+ return res.json({ message: 'Agent deleted' });
+ } catch (error) {
+ logger.error('[/Agents/:id] Error deleting Agent', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Lists agents using ACL-aware permissions (ownership + explicit shares).
+ * @route GET /Agents
+ * @param {object} req - Express Request
+ * @param {object} req.query - Request query
+ * @param {string} [req.query.user] - The user ID of the agent's author.
+ * @returns {Promise} 200 - success response - application/json
+ */
+const getListAgentsHandler = async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const { category, search, limit, cursor, promoted } = req.query;
+ let requiredPermission = req.query.requiredPermission;
+ if (typeof requiredPermission === 'string') {
+ requiredPermission = parseInt(requiredPermission, 10);
+ if (isNaN(requiredPermission)) {
+ requiredPermission = PermissionBits.VIEW;
+ }
+ } else if (typeof requiredPermission !== 'number') {
+ requiredPermission = PermissionBits.VIEW;
+ }
+ // Base filter
+ const filter = {};
+
+ // Handle category filter - only apply if category is defined
+ if (category !== undefined && category.trim() !== '') {
+ filter.category = category;
+ }
+
+ // Handle promoted filter - only from query param
+ if (promoted === '1') {
+ filter.is_promoted = true;
+ } else if (promoted === '0') {
+ filter.is_promoted = { $ne: true };
+ }
+
+ // Handle search filter (escape regex and cap length)
+ if (search && search.trim() !== '') {
+ const safeSearch = escapeRegex(search.trim().slice(0, MAX_SEARCH_LEN));
+ const regex = new RegExp(safeSearch, 'i');
+ filter.$or = [{ name: regex }, { description: regex }];
+ }
+
+ // Get agent IDs the user has VIEW access to via ACL
+ const accessibleIds = await findAccessibleResources({
+ userId,
+ role: req.user.role,
+ resourceType: ResourceType.AGENT,
+ requiredPermissions: requiredPermission,
+ });
+
+ const publiclyAccessibleIds = await findPubliclyAccessibleResources({
+ resourceType: ResourceType.AGENT,
+ requiredPermissions: PermissionBits.VIEW,
+ });
+
+ // Use the new ACL-aware function
+ const data = await getListAgentsByAccess({
+ accessibleIds,
+ otherParams: filter,
+ limit,
+ after: cursor,
+ });
+
+ const agents = data?.data ?? [];
+ if (!agents.length) {
+ return res.json(data);
+ }
+
+ const publicSet = new Set(publiclyAccessibleIds.map((oid) => oid.toString()));
+
+ data.data = agents.map((agent) => {
+ try {
+ if (agent?._id && publicSet.has(agent._id.toString())) {
+ agent.isPublic = true;
+ }
+ } catch (e) {
+ // Silently ignore mapping errors
+ void e;
+ }
+ return agent;
+ });
+
+ // Opportunistically refresh S3 avatar URLs for list results with caching
+ try {
+ await refreshListAvatars(data.data, req.user.id);
+ } catch (err) {
+ logger.debug('[/Agents] Skipping avatar refresh for list', err);
+ }
+ return res.json(data);
+ } catch (error) {
+ logger.error('[/Agents] Error listing Agents', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Uploads and updates an avatar for a specific agent.
+ * @route POST /:agent_id/avatar
+ * @param {object} req - Express Request
+ * @param {object} req.params - Request params
+ * @param {string} req.params.agent_id - The ID of the agent.
+ * @param {Express.Multer.File} req.file - The avatar image file.
+ * @param {object} req.body - Request body
+ * @param {string} [req.body.avatar] - Optional avatar for the agent's avatar.
+ * @returns {Promise} 200 - success response - application/json
+ */
+const uploadAgentAvatarHandler = async (req, res) => {
+ try {
+ const appConfig = req.config;
+ if (!req.file) {
+ return res.status(400).json({ message: 'No file uploaded' });
+ }
+ filterFile({ req, file: req.file, image: true, isAvatar: true });
+ const { agent_id } = req.params;
+ if (!agent_id) {
+ return res.status(400).json({ message: 'Agent ID is required' });
+ }
+
+ const existingAgent = await getAgent({ id: agent_id });
+
+ if (!existingAgent) {
+ return res.status(404).json({ error: 'Agent not found' });
+ }
+
+ const buffer = await fs.readFile(req.file.path);
+ const fileStrategy = getFileStrategy(appConfig, { isAvatar: true });
+ const resizedBuffer = await resizeAvatar({
+ userId: req.user.id,
+ input: buffer,
+ });
+
+ const { processAvatar } = getStrategyFunctions(fileStrategy);
+ const avatarUrl = await processAvatar({
+ buffer: resizedBuffer,
+ userId: req.user.id,
+ manual: 'false',
+ agentId: agent_id,
+ });
+
+ const image = {
+ filepath: avatarUrl,
+ source: fileStrategy,
+ };
+
+ let _avatar = existingAgent.avatar;
+
+ if (_avatar && _avatar.source) {
+ const { deleteFile } = getStrategyFunctions(_avatar.source);
+ try {
+ await deleteFile(req, { filepath: _avatar.filepath });
+ await deleteFileByFilter({ user: req.user.id, filepath: _avatar.filepath });
+ } catch (error) {
+ logger.error('[/:agent_id/avatar] Error deleting old avatar', error);
+ }
+ }
+
+ const data = {
+ avatar: {
+ filepath: image.filepath,
+ source: image.source,
+ },
+ };
+
+ const updatedAgent = await updateAgent({ id: agent_id }, data, {
+ updatingUserId: req.user.id,
+ });
+ res.status(201).json(updatedAgent);
+ } catch (error) {
+ const message = 'An error occurred while updating the Agent Avatar';
+ logger.error(
+ `[/:agent_id/avatar] ${message} (${req.params?.agent_id ?? 'unknown agent'})`,
+ error,
+ );
+ res.status(500).json({ message });
+ } finally {
+ try {
+ await fs.unlink(req.file.path);
+ logger.debug('[/:agent_id/avatar] Temp. image upload file deleted');
+ } catch {
+ logger.debug('[/:agent_id/avatar] Temp. image upload file already deleted');
+ }
+ }
+};
+
+/**
+ * Reverts an agent to a previous version from its version history.
+ * @route PATCH /agents/:id/revert
+ * @param {object} req - Express Request object
+ * @param {object} req.params - Request parameters
+ * @param {string} req.params.id - The ID of the agent to revert
+ * @param {object} req.body - Request body
+ * @param {number} req.body.version_index - The index of the version to revert to
+ * @param {object} req.user - Authenticated user information
+ * @param {string} req.user.id - User ID
+ * @param {string} req.user.role - User role
+ * @param {ServerResponse} res - Express Response object
+ * @returns {Promise} 200 - The updated agent after reverting to the specified version
+ * @throws {Error} 400 - If version_index is missing
+ * @throws {Error} 403 - If user doesn't have permission to modify the agent
+ * @throws {Error} 404 - If agent not found
+ * @throws {Error} 500 - If there's an internal server error during the reversion process
+ */
+const revertAgentVersionHandler = async (req, res) => {
+ try {
+ const { id } = req.params;
+ const { version_index } = req.body;
+
+ if (version_index === undefined) {
+ return res.status(400).json({ error: 'version_index is required' });
+ }
+
+ const existingAgent = await getAgent({ id });
+
+ if (!existingAgent) {
+ return res.status(404).json({ error: 'Agent not found' });
+ }
+
+ // Permissions are enforced via route middleware (ACL EDIT)
+
+ const updatedAgent = await revertAgentVersion({ id }, version_index);
+
+ if (updatedAgent.author) {
+ updatedAgent.author = updatedAgent.author.toString();
+ }
+
+ if (updatedAgent.author !== req.user.id) {
+ delete updatedAgent.author;
+ }
+
+ return res.json(updatedAgent);
+ } catch (error) {
+ logger.error('[/agents/:id/revert] Error reverting Agent version', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+/**
+ * Get all agent categories with counts
+ *
+ * @param {Object} _req - Express request object (unused)
+ * @param {Object} res - Express response object
+ */
+const getAgentCategories = async (_req, res) => {
+ try {
+ const categories = await getCategoriesWithCounts();
+ const promotedCount = await countPromotedAgents();
+ const formattedCategories = categories.map((category) => ({
+ value: category.value,
+ label: category.label,
+ count: category.agentCount,
+ description: category.description,
+ }));
+
+ if (promotedCount > 0) {
+ formattedCategories.unshift({
+ value: 'promoted',
+ label: 'Promoted',
+ count: promotedCount,
+ description: 'Our recommended agents',
+ });
+ }
+
+ formattedCategories.push({
+ value: 'all',
+ label: 'All',
+ description: 'All available agents',
+ });
+
+ res.status(200).json(formattedCategories);
+ } catch (error) {
+ logger.error('[/Agents/Marketplace] Error fetching agent categories:', error);
+ res.status(500).json({
+ error: 'Failed to fetch agent categories',
+ userMessage: 'Unable to load categories. Please refresh the page.',
+ suggestion: 'Try refreshing the page or check your network connection',
+ });
+ }
+};
+module.exports = {
+ createAgent: createAgentHandler,
+ getAgent: getAgentHandler,
+ updateAgent: updateAgentHandler,
+ duplicateAgent: duplicateAgentHandler,
+ deleteAgent: deleteAgentHandler,
+ getListAgents: getListAgentsHandler,
+ uploadAgentAvatar: uploadAgentAvatarHandler,
+ revertAgentVersion: revertAgentVersionHandler,
+ getAgentCategories,
+};
diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js
new file mode 100644
index 0000000000..bfdee7eb79
--- /dev/null
+++ b/api/server/controllers/agents/v1.spec.js
@@ -0,0 +1,1127 @@
+const mongoose = require('mongoose');
+const { v4: uuidv4 } = require('uuid');
+const { nanoid } = require('nanoid');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { agentSchema } = require('@librechat/data-schemas');
+
+// Only mock the dependencies that are not database-related
+jest.mock('~/server/services/Config', () => ({
+ getCachedTools: jest.fn().mockResolvedValue({
+ web_search: true,
+ execute_code: true,
+ file_search: true,
+ }),
+}));
+
+jest.mock('~/models/Project', () => ({
+ getProjectByName: jest.fn().mockResolvedValue(null),
+}));
+
+jest.mock('~/server/services/Files/strategies', () => ({
+ getStrategyFunctions: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/images/avatar', () => ({
+ resizeAvatar: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/S3/crud', () => ({
+ refreshS3Url: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/process', () => ({
+ filterFile: jest.fn(),
+}));
+
+jest.mock('~/models/Action', () => ({
+ updateAction: jest.fn(),
+ getActions: jest.fn().mockResolvedValue([]),
+}));
+
+jest.mock('~/models/File', () => ({
+ deleteFileByFilter: jest.fn(),
+}));
+
+jest.mock('~/server/services/PermissionService', () => ({
+ findAccessibleResources: jest.fn().mockResolvedValue([]),
+ findPubliclyAccessibleResources: jest.fn().mockResolvedValue([]),
+ grantPermission: jest.fn(),
+ hasPublicPermission: jest.fn().mockResolvedValue(false),
+ checkPermission: jest.fn().mockResolvedValue(true),
+}));
+
+jest.mock('~/models', () => ({
+ getCategoriesWithCounts: jest.fn(),
+}));
+
+const {
+ createAgent: createAgentHandler,
+ updateAgent: updateAgentHandler,
+ getListAgents: getListAgentsHandler,
+} = require('./v1');
+
+const {
+ findAccessibleResources,
+ findPubliclyAccessibleResources,
+} = require('~/server/services/PermissionService');
+
+/**
+ * @type {import('mongoose').Model}
+ */
+let Agent;
+
+describe('Agent Controllers - Mass Assignment Protection', () => {
+ let mongoServer;
+ let mockReq;
+ let mockRes;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+ Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
+ }, 20000);
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ await Agent.deleteMany({});
+
+ // Reset all mocks
+ jest.clearAllMocks();
+
+ // Setup mock request and response objects
+ mockReq = {
+ user: {
+ id: new mongoose.Types.ObjectId().toString(),
+ role: 'USER',
+ },
+ body: {},
+ params: {},
+ query: {},
+ app: {
+ locals: {
+ fileStrategy: 'local',
+ },
+ },
+ };
+
+ mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ };
+ });
+
+ describe('createAgentHandler', () => {
+ test('should create agent with allowed fields only', async () => {
+ const validData = {
+ name: 'Test Agent',
+ description: 'A test agent',
+ instructions: 'Be helpful',
+ provider: 'openai',
+ model: 'gpt-4',
+ tools: ['web_search'],
+ model_parameters: { temperature: 0.7 },
+ tool_resources: {
+ file_search: { file_ids: ['file1', 'file2'] },
+ },
+ };
+
+ mockReq.body = validData;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+ expect(mockRes.json).toHaveBeenCalled();
+
+ const createdAgent = mockRes.json.mock.calls[0][0];
+ expect(createdAgent.name).toBe('Test Agent');
+ expect(createdAgent.description).toBe('A test agent');
+ expect(createdAgent.provider).toBe('openai');
+ expect(createdAgent.model).toBe('gpt-4');
+ expect(createdAgent.author.toString()).toBe(mockReq.user.id);
+ expect(createdAgent.tools).toContain('web_search');
+
+ // Verify in database
+ const agentInDb = await Agent.findOne({ id: createdAgent.id });
+ expect(agentInDb).toBeDefined();
+ expect(agentInDb.name).toBe('Test Agent');
+ expect(agentInDb.author.toString()).toBe(mockReq.user.id);
+ });
+
+ test('should reject creation with unauthorized fields (mass assignment protection)', async () => {
+ const maliciousData = {
+ // Required fields
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'Malicious Agent',
+
+ // Unauthorized fields that should be stripped
+ author: new mongoose.Types.ObjectId().toString(), // Should not be able to set author
+ authorName: 'Hacker', // Should be stripped
+ isCollaborative: true, // Should be stripped on creation
+ versions: [], // Should be stripped
+ _id: new mongoose.Types.ObjectId(), // Should be stripped
+ id: 'custom_agent_id', // Should be overridden
+ createdAt: new Date('2020-01-01'), // Should be stripped
+ updatedAt: new Date('2020-01-01'), // Should be stripped
+ };
+
+ mockReq.body = maliciousData;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+
+ const createdAgent = mockRes.json.mock.calls[0][0];
+
+ // Verify unauthorized fields were not set
+ expect(createdAgent.author.toString()).toBe(mockReq.user.id); // Should be the request user, not the malicious value
+ expect(createdAgent.authorName).toBeUndefined();
+ expect(createdAgent.isCollaborative).toBeFalsy();
+ expect(createdAgent.versions).toHaveLength(1); // Should have exactly 1 version from creation
+ expect(createdAgent.id).not.toBe('custom_agent_id'); // Should have generated ID
+ expect(createdAgent.id).toMatch(/^agent_/); // Should have proper prefix
+
+ // Verify timestamps are recent (not the malicious dates)
+ const createdTime = new Date(createdAgent.createdAt).getTime();
+ const now = Date.now();
+ expect(now - createdTime).toBeLessThan(5000); // Created within last 5 seconds
+
+ // Verify in database
+ const agentInDb = await Agent.findOne({ id: createdAgent.id });
+ expect(agentInDb.author.toString()).toBe(mockReq.user.id);
+ expect(agentInDb.authorName).toBeUndefined();
+ });
+
+ test('should validate required fields', async () => {
+ const invalidData = {
+ name: 'Missing Required Fields',
+ // Missing provider and model
+ };
+
+ mockReq.body = invalidData;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(400);
+ expect(mockRes.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: 'Invalid request data',
+ details: expect.any(Array),
+ }),
+ );
+
+ // Verify nothing was created in database
+ const count = await Agent.countDocuments();
+ expect(count).toBe(0);
+ });
+
+ test('should handle tool_resources validation', async () => {
+ const dataWithInvalidToolResources = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'Agent with Tool Resources',
+ tool_resources: {
+ // Valid resources
+ file_search: {
+ file_ids: ['file1', 'file2'],
+ vector_store_ids: ['vs1'],
+ },
+ execute_code: {
+ file_ids: ['file3'],
+ },
+ // Invalid resource (should be stripped by schema)
+ invalid_resource: {
+ file_ids: ['file4'],
+ },
+ },
+ };
+
+ mockReq.body = dataWithInvalidToolResources;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+
+ const createdAgent = mockRes.json.mock.calls[0][0];
+ expect(createdAgent.tool_resources).toBeDefined();
+ expect(createdAgent.tool_resources.file_search).toBeDefined();
+ expect(createdAgent.tool_resources.execute_code).toBeDefined();
+ expect(createdAgent.tool_resources.invalid_resource).toBeUndefined(); // Should be stripped
+
+ // Verify in database
+ const agentInDb = await Agent.findOne({ id: createdAgent.id });
+ expect(agentInDb.tool_resources.invalid_resource).toBeUndefined();
+ });
+
+ test('should handle support_contact with empty strings', async () => {
+ const dataWithEmptyContact = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'Agent with Empty Contact',
+ support_contact: {
+ name: '',
+ email: '',
+ },
+ };
+
+ mockReq.body = dataWithEmptyContact;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+
+ const createdAgent = mockRes.json.mock.calls[0][0];
+ expect(createdAgent.name).toBe('Agent with Empty Contact');
+ expect(createdAgent.support_contact).toBeDefined();
+ expect(createdAgent.support_contact.name).toBe('');
+ expect(createdAgent.support_contact.email).toBe('');
+ });
+
+ test('should handle support_contact with valid email', async () => {
+ const dataWithValidContact = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'Agent with Valid Contact',
+ support_contact: {
+ name: 'Support Team',
+ email: 'support@example.com',
+ },
+ };
+
+ mockReq.body = dataWithValidContact;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+
+ const createdAgent = mockRes.json.mock.calls[0][0];
+ expect(createdAgent.support_contact).toBeDefined();
+ expect(createdAgent.support_contact.name).toBe('Support Team');
+ expect(createdAgent.support_contact.email).toBe('support@example.com');
+ });
+
+ test('should reject support_contact with invalid email', async () => {
+ const dataWithInvalidEmail = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'Agent with Invalid Email',
+ support_contact: {
+ name: 'Support',
+ email: 'not-an-email',
+ },
+ };
+
+ mockReq.body = dataWithInvalidEmail;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(400);
+ expect(mockRes.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: 'Invalid request data',
+ details: expect.arrayContaining([
+ expect.objectContaining({
+ path: ['support_contact', 'email'],
+ }),
+ ]),
+ }),
+ );
+ });
+
+ test('should handle avatar validation', async () => {
+ const dataWithAvatar = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'Agent with Avatar',
+ avatar: {
+ filepath: 'https://example.com/avatar.png',
+ source: 's3',
+ },
+ };
+
+ mockReq.body = dataWithAvatar;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+
+ const createdAgent = mockRes.json.mock.calls[0][0];
+ expect(createdAgent.avatar).toEqual({
+ filepath: 'https://example.com/avatar.png',
+ source: 's3',
+ });
+ });
+
+ test('should handle invalid avatar format', async () => {
+ const dataWithInvalidAvatar = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'Agent with Invalid Avatar',
+ avatar: 'just-a-string', // Invalid format
+ };
+
+ mockReq.body = dataWithInvalidAvatar;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(400);
+ expect(mockRes.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: 'Invalid request data',
+ }),
+ );
+ });
+ });
+
+ describe('updateAgentHandler', () => {
+ let existingAgentId;
+ let existingAgentAuthorId;
+
+ beforeEach(async () => {
+ // Create an existing agent for update tests
+ existingAgentAuthorId = new mongoose.Types.ObjectId();
+ const agent = await Agent.create({
+ id: `agent_${uuidv4()}`,
+ name: 'Original Agent',
+ provider: 'openai',
+ model: 'gpt-3.5-turbo',
+ author: existingAgentAuthorId,
+ description: 'Original description',
+ isCollaborative: false,
+ versions: [
+ {
+ name: 'Original Agent',
+ provider: 'openai',
+ model: 'gpt-3.5-turbo',
+ description: 'Original description',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+ existingAgentId = agent.id;
+ });
+
+ test('should update agent with allowed fields only', async () => {
+ mockReq.user.id = existingAgentAuthorId.toString(); // Set as author
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ name: 'Updated Agent',
+ description: 'Updated description',
+ model: 'gpt-4',
+ isCollaborative: true, // This IS allowed in updates
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).not.toHaveBeenCalledWith(400);
+ expect(mockRes.status).not.toHaveBeenCalledWith(403);
+ expect(mockRes.json).toHaveBeenCalled();
+
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ expect(updatedAgent.name).toBe('Updated Agent');
+ expect(updatedAgent.description).toBe('Updated description');
+ expect(updatedAgent.model).toBe('gpt-4');
+ expect(updatedAgent.isCollaborative).toBe(true);
+ expect(updatedAgent.author).toBe(existingAgentAuthorId.toString());
+
+ // Verify in database
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(agentInDb.name).toBe('Updated Agent');
+ expect(agentInDb.isCollaborative).toBe(true);
+ });
+
+ test('should reject update with unauthorized fields (mass assignment protection)', async () => {
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ name: 'Updated Name',
+
+ // Unauthorized fields that should be stripped
+ author: new mongoose.Types.ObjectId().toString(), // Should not be able to change author
+ authorName: 'Hacker', // Should be stripped
+ id: 'different_agent_id', // Should be stripped
+ _id: new mongoose.Types.ObjectId(), // Should be stripped
+ versions: [], // Should be stripped
+ createdAt: new Date('2020-01-01'), // Should be stripped
+ updatedAt: new Date('2020-01-01'), // Should be stripped
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+
+ // Verify unauthorized fields were not changed
+ expect(updatedAgent.author).toBe(existingAgentAuthorId.toString()); // Should not have changed
+ expect(updatedAgent.authorName).toBeUndefined();
+ expect(updatedAgent.id).toBe(existingAgentId); // Should not have changed
+ expect(updatedAgent.name).toBe('Updated Name'); // Only this should have changed
+
+ // Verify in database
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(agentInDb.author.toString()).toBe(existingAgentAuthorId.toString());
+ expect(agentInDb.id).toBe(existingAgentId);
+ });
+
+ test('should allow admin to update any agent', async () => {
+ const adminUserId = new mongoose.Types.ObjectId().toString();
+ mockReq.user.id = adminUserId;
+ mockReq.user.role = 'ADMIN'; // Set as admin
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ name: 'Admin Update',
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).not.toHaveBeenCalledWith(403);
+ expect(mockRes.json).toHaveBeenCalled();
+
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ expect(updatedAgent.name).toBe('Admin Update');
+ });
+
+ test('should handle projectIds updates', async () => {
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+
+ const projectId1 = new mongoose.Types.ObjectId().toString();
+ const projectId2 = new mongoose.Types.ObjectId().toString();
+
+ mockReq.body = {
+ projectIds: [projectId1, projectId2],
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ expect(updatedAgent).toBeDefined();
+ // Note: updateAgentProjects requires more setup, so we just verify the handler doesn't crash
+ });
+
+ test('should validate tool_resources in updates', async () => {
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ tool_resources: {
+ /** Legacy conversion from `ocr` to `context` */
+ ocr: {
+ file_ids: ['ocr1', 'ocr2'],
+ },
+ execute_code: {
+ file_ids: ['img1'],
+ },
+ // Invalid tool resource
+ invalid_tool: {
+ file_ids: ['invalid'],
+ },
+ },
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ expect(updatedAgent.tool_resources).toBeDefined();
+ expect(updatedAgent.tool_resources.ocr).toBeUndefined();
+ expect(updatedAgent.tool_resources.context).toBeDefined();
+ expect(updatedAgent.tool_resources.execute_code).toBeDefined();
+ expect(updatedAgent.tool_resources.invalid_tool).toBeUndefined();
+ });
+
+ test('should return 404 for non-existent agent', async () => {
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = `agent_${uuidv4()}`; // Non-existent ID
+ mockReq.body = {
+ name: 'Update Non-existent',
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(404);
+ expect(mockRes.json).toHaveBeenCalledWith({ error: 'Agent not found' });
+ });
+
+ test('should include version field in update response', async () => {
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ name: 'Updated with Version Check',
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.json).toHaveBeenCalled();
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+
+ // Verify version field is included and is a number
+ expect(updatedAgent).toHaveProperty('version');
+ expect(typeof updatedAgent.version).toBe('number');
+ expect(updatedAgent.version).toBeGreaterThanOrEqual(1);
+
+ // Verify in database
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(updatedAgent.version).toBe(agentInDb.versions.length);
+ });
+
+ test('should allow resetting avatar when value is explicitly null', async () => {
+ await Agent.updateOne(
+ { id: existingAgentId },
+ {
+ avatar: {
+ filepath: 'https://example.com/avatar.png',
+ source: 's3',
+ },
+ },
+ );
+
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ avatar: null,
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ expect(updatedAgent.avatar).toBeNull();
+
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(agentInDb.avatar).toBeNull();
+ });
+
+ test('should ignore avatar field when value is undefined', async () => {
+ const originalAvatar = {
+ filepath: 'https://example.com/original.png',
+ source: 's3',
+ };
+ await Agent.updateOne({ id: existingAgentId }, { avatar: originalAvatar });
+
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ avatar: undefined,
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(agentInDb.avatar.filepath).toBe(originalAvatar.filepath);
+ expect(agentInDb.avatar.source).toBe(originalAvatar.source);
+ });
+
+ test('should not bump version when no mutable fields change', async () => {
+ const existingAgent = await Agent.findOne({ id: existingAgentId });
+ const originalVersionCount = existingAgent.versions.length;
+
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ avatar: undefined,
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(agentInDb.versions.length).toBe(originalVersionCount);
+ });
+
+ test('should handle validation errors properly', async () => {
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ model_parameters: 'invalid-not-an-object', // Should be an object
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(400);
+ expect(mockRes.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: 'Invalid request data',
+ details: expect.any(Array),
+ }),
+ );
+ });
+ });
+
+ describe('Mass Assignment Attack Scenarios', () => {
+ test('should prevent setting system fields during creation', async () => {
+ const systemFields = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'System Fields Test',
+
+ // System fields that should never be settable by users
+ __v: 99,
+ _id: new mongoose.Types.ObjectId(),
+ versions: [
+ {
+ name: 'Fake Version',
+ provider: 'fake',
+ model: 'fake-model',
+ },
+ ],
+ };
+
+ mockReq.body = systemFields;
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+
+ const createdAgent = mockRes.json.mock.calls[0][0];
+
+ // Verify system fields were not affected
+ expect(createdAgent.__v).not.toBe(99);
+ expect(createdAgent.versions).toHaveLength(1); // Should only have the auto-created version
+ expect(createdAgent.versions[0].name).toBe('System Fields Test'); // From actual creation
+ expect(createdAgent.versions[0].provider).toBe('openai'); // From actual creation
+
+ // Verify in database
+ const agentInDb = await Agent.findOne({ id: createdAgent.id });
+ expect(agentInDb.__v).not.toBe(99);
+ });
+
+ test('should prevent author hijacking', async () => {
+ const originalAuthorId = new mongoose.Types.ObjectId();
+ const attackerId = new mongoose.Types.ObjectId();
+
+ // Admin creates an agent
+ mockReq.user.id = originalAuthorId.toString();
+ mockReq.user.role = 'ADMIN';
+ mockReq.body = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'Admin Agent',
+ author: attackerId.toString(), // Trying to set different author
+ };
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+
+ const createdAgent = mockRes.json.mock.calls[0][0];
+
+ // Author should be the actual user, not the attempted value
+ expect(createdAgent.author.toString()).toBe(originalAuthorId.toString());
+ expect(createdAgent.author.toString()).not.toBe(attackerId.toString());
+
+ // Verify in database
+ const agentInDb = await Agent.findOne({ id: createdAgent.id });
+ expect(agentInDb.author.toString()).toBe(originalAuthorId.toString());
+ });
+
+ test('should strip unknown fields to prevent future vulnerabilities', async () => {
+ mockReq.body = {
+ provider: 'openai',
+ model: 'gpt-4',
+ name: 'Future Proof Test',
+
+ // Unknown fields that might be added in future
+ superAdminAccess: true,
+ bypassAllChecks: true,
+ internalFlag: 'secret',
+ futureFeature: 'exploit',
+ };
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+
+ const createdAgent = mockRes.json.mock.calls[0][0];
+
+ // Verify unknown fields were stripped
+ expect(createdAgent.superAdminAccess).toBeUndefined();
+ expect(createdAgent.bypassAllChecks).toBeUndefined();
+ expect(createdAgent.internalFlag).toBeUndefined();
+ expect(createdAgent.futureFeature).toBeUndefined();
+
+ // Also check in database
+ const agentInDb = await Agent.findOne({ id: createdAgent.id }).lean();
+ expect(agentInDb.superAdminAccess).toBeUndefined();
+ expect(agentInDb.bypassAllChecks).toBeUndefined();
+ expect(agentInDb.internalFlag).toBeUndefined();
+ expect(agentInDb.futureFeature).toBeUndefined();
+ });
+ });
+
+ describe('getListAgentsHandler - Security Tests', () => {
+ let userA, userB;
+ let agentA1, agentA2, agentA3, agentB1;
+
+ beforeEach(async () => {
+ await Agent.deleteMany({});
+ jest.clearAllMocks();
+
+ // Create two test users
+ userA = new mongoose.Types.ObjectId();
+ userB = new mongoose.Types.ObjectId();
+
+ // Create agents for User A
+ agentA1 = await Agent.create({
+ id: `agent_${nanoid(12)}`,
+ name: 'Agent A1',
+ description: 'User A agent 1',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userA,
+ versions: [
+ {
+ name: 'Agent A1',
+ description: 'User A agent 1',
+ provider: 'openai',
+ model: 'gpt-4',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+
+ agentA2 = await Agent.create({
+ id: `agent_${nanoid(12)}`,
+ name: 'Agent A2',
+ description: 'User A agent 2',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userA,
+ versions: [
+ {
+ name: 'Agent A2',
+ description: 'User A agent 2',
+ provider: 'openai',
+ model: 'gpt-4',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+
+ agentA3 = await Agent.create({
+ id: `agent_${nanoid(12)}`,
+ name: 'Agent A3',
+ description: 'User A agent 3',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userA,
+ category: 'productivity',
+ versions: [
+ {
+ name: 'Agent A3',
+ description: 'User A agent 3',
+ provider: 'openai',
+ model: 'gpt-4',
+ category: 'productivity',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+
+ // Create an agent for User B
+ agentB1 = await Agent.create({
+ id: `agent_${nanoid(12)}`,
+ name: 'Agent B1',
+ description: 'User B agent 1',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userB,
+ versions: [
+ {
+ name: 'Agent B1',
+ description: 'User B agent 1',
+ provider: 'openai',
+ model: 'gpt-4',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+ });
+
+ test('should return empty list when user has no accessible agents', async () => {
+ // User B has no permissions and no owned agents
+ mockReq.user.id = userB.toString();
+ findAccessibleResources.mockResolvedValue([]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ expect(findAccessibleResources).toHaveBeenCalledWith({
+ userId: userB.toString(),
+ role: 'USER',
+ resourceType: 'agent',
+ requiredPermissions: 1, // VIEW permission
+ });
+
+ expect(mockRes.json).toHaveBeenCalledWith({
+ object: 'list',
+ data: [],
+ first_id: null,
+ last_id: null,
+ has_more: false,
+ after: null,
+ });
+ });
+
+ test('should not return other users agents when accessibleIds is empty', async () => {
+ // User B trying to see agents with no permissions
+ mockReq.user.id = userB.toString();
+ findAccessibleResources.mockResolvedValue([]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(0);
+
+ // Verify User A's agents are not included
+ const agentIds = response.data.map((a) => a.id);
+ expect(agentIds).not.toContain(agentA1.id);
+ expect(agentIds).not.toContain(agentA2.id);
+ expect(agentIds).not.toContain(agentA3.id);
+ });
+
+ test('should only return agents user has access to', async () => {
+ // User B has access to one of User A's agents
+ mockReq.user.id = userB.toString();
+ findAccessibleResources.mockResolvedValue([agentA1._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(1);
+ expect(response.data[0].id).toBe(agentA1.id);
+ expect(response.data[0].name).toBe('Agent A1');
+ });
+
+ test('should return multiple accessible agents', async () => {
+ // User B has access to multiple agents
+ mockReq.user.id = userB.toString();
+ findAccessibleResources.mockResolvedValue([agentA1._id, agentA3._id, agentB1._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(3);
+
+ const agentIds = response.data.map((a) => a.id);
+ expect(agentIds).toContain(agentA1.id);
+ expect(agentIds).toContain(agentA3.id);
+ expect(agentIds).toContain(agentB1.id);
+ expect(agentIds).not.toContain(agentA2.id);
+ });
+
+ test('should apply category filter correctly with ACL', async () => {
+ // User has access to all agents but filters by category
+ mockReq.user.id = userB.toString();
+ mockReq.query.category = 'productivity';
+ findAccessibleResources.mockResolvedValue([agentA1._id, agentA2._id, agentA3._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(1);
+ expect(response.data[0].id).toBe(agentA3.id);
+ expect(response.data[0].category).toBe('productivity');
+ });
+
+ test('should apply search filter correctly with ACL', async () => {
+ // User has access to multiple agents but searches for specific one
+ mockReq.user.id = userB.toString();
+ mockReq.query.search = 'A2';
+ findAccessibleResources.mockResolvedValue([agentA1._id, agentA2._id, agentA3._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(1);
+ expect(response.data[0].id).toBe(agentA2.id);
+ });
+
+ test('should handle pagination with ACL filtering', async () => {
+ // Create more agents for pagination testing
+ const moreAgents = [];
+ for (let i = 4; i <= 10; i++) {
+ const agent = await Agent.create({
+ id: `agent_${nanoid(12)}`,
+ name: `Agent A${i}`,
+ description: `User A agent ${i}`,
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userA,
+ versions: [
+ {
+ name: `Agent A${i}`,
+ description: `User A agent ${i}`,
+ provider: 'openai',
+ model: 'gpt-4',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+ moreAgents.push(agent);
+ }
+
+ // User has access to all agents
+ const allAgentIds = [agentA1, agentA2, agentA3, ...moreAgents].map((a) => a._id);
+ mockReq.user.id = userB.toString();
+ mockReq.query.limit = '5';
+ findAccessibleResources.mockResolvedValue(allAgentIds);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(5);
+ expect(response.has_more).toBe(true);
+ expect(response.after).toBeTruthy();
+ });
+
+ test('should mark publicly accessible agents', async () => {
+ // User has access to agents, some are public
+ mockReq.user.id = userB.toString();
+ findAccessibleResources.mockResolvedValue([agentA1._id, agentA2._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([agentA2._id]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(2);
+
+ const publicAgent = response.data.find((a) => a.id === agentA2.id);
+ const privateAgent = response.data.find((a) => a.id === agentA1.id);
+
+ expect(publicAgent.isPublic).toBe(true);
+ expect(privateAgent.isPublic).toBeUndefined();
+ });
+
+ test('should handle requiredPermission parameter', async () => {
+ // Test with different permission levels
+ mockReq.user.id = userB.toString();
+ mockReq.query.requiredPermission = '15'; // FULL_ACCESS
+ findAccessibleResources.mockResolvedValue([agentA1._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ expect(findAccessibleResources).toHaveBeenCalledWith({
+ userId: userB.toString(),
+ role: 'USER',
+ resourceType: 'agent',
+ requiredPermissions: 15,
+ });
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(1);
+ });
+
+ test('should handle promoted filter with ACL', async () => {
+ // Create a promoted agent
+ const promotedAgent = await Agent.create({
+ id: `agent_${nanoid(12)}`,
+ name: 'Promoted Agent',
+ description: 'A promoted agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userA,
+ is_promoted: true,
+ versions: [
+ {
+ name: 'Promoted Agent',
+ description: 'A promoted agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ is_promoted: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+
+ mockReq.user.id = userB.toString();
+ mockReq.query.promoted = '1';
+ findAccessibleResources.mockResolvedValue([agentA1._id, agentA2._id, promotedAgent._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(1);
+ expect(response.data[0].id).toBe(promotedAgent.id);
+ expect(response.data[0].is_promoted).toBe(true);
+ });
+
+ test('should handle errors gracefully', async () => {
+ mockReq.user.id = userB.toString();
+ findAccessibleResources.mockRejectedValue(new Error('Permission service error'));
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(500);
+ expect(mockRes.json).toHaveBeenCalledWith({
+ error: 'Permission service error',
+ });
+ });
+
+ test('should respect combined filters with ACL', async () => {
+ // Create agents with specific attributes
+ const productivityPromoted = await Agent.create({
+ id: `agent_${nanoid(12)}`,
+ name: 'Productivity Pro',
+ description: 'A promoted productivity agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: userA,
+ category: 'productivity',
+ is_promoted: true,
+ versions: [
+ {
+ name: 'Productivity Pro',
+ description: 'A promoted productivity agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ category: 'productivity',
+ is_promoted: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ],
+ });
+
+ mockReq.user.id = userB.toString();
+ mockReq.query.category = 'productivity';
+ mockReq.query.promoted = '1';
+ findAccessibleResources.mockResolvedValue([
+ agentA1._id,
+ agentA2._id,
+ agentA3._id,
+ productivityPromoted._id,
+ ]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(1);
+ expect(response.data[0].id).toBe(productivityPromoted.id);
+ expect(response.data[0].category).toBe('productivity');
+ expect(response.data[0].is_promoted).toBe(true);
+ });
+ });
+});
diff --git a/api/server/controllers/assistants/chatV1.js b/api/server/controllers/assistants/chatV1.js
new file mode 100644
index 0000000000..804594d0bf
--- /dev/null
+++ b/api/server/controllers/assistants/chatV1.js
@@ -0,0 +1,655 @@
+const { v4 } = require('uuid');
+const { sleep } = require('@librechat/agents');
+const { logger } = require('@librechat/data-schemas');
+const { sendEvent, getBalanceConfig, getModelMaxTokens, countTokens } = require('@librechat/api');
+const {
+ Time,
+ Constants,
+ RunStatus,
+ CacheKeys,
+ VisionModes,
+ ContentTypes,
+ EModelEndpoint,
+ ViolationTypes,
+ ImageVisionTool,
+ checkOpenAIStorage,
+ AssistantStreamEvents,
+} = require('librechat-data-provider');
+const {
+ initThread,
+ recordUsage,
+ saveUserMessage,
+ checkMessageGaps,
+ addThreadMetadata,
+ saveAssistantMessage,
+} = require('~/server/services/Threads');
+const { runAssistant, createOnTextProgress } = require('~/server/services/AssistantService');
+const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
+const { formatMessage, createVisionPrompt } = require('~/app/clients/prompts');
+const { encodeAndFormat } = require('~/server/services/Files/images/encode');
+const { createRun, StreamRunManager } = require('~/server/services/Runs');
+const { addTitle } = require('~/server/services/Endpoints/assistants');
+const { createRunBody } = require('~/server/services/createRunBody');
+const { sendResponse } = require('~/server/middleware/error');
+const { getTransactions } = require('~/models/Transaction');
+const { checkBalance } = require('~/models/balanceMethods');
+const { getConvo } = require('~/models/Conversation');
+const getLogStores = require('~/cache/getLogStores');
+const { getOpenAIClient } = require('./helpers');
+
+/**
+ * @route POST /
+ * @desc Chat with an assistant
+ * @access Public
+ * @param {object} req - The request object, containing the request data.
+ * @param {object} req.body - The request payload.
+ * @param {Express.Response} res - The response object, used to send back a response.
+ * @returns {void}
+ */
+const chatV1 = async (req, res) => {
+ const appConfig = req.config;
+ logger.debug('[/assistants/chat/] req.body', req.body);
+
+ const {
+ text,
+ model,
+ endpoint,
+ files = [],
+ promptPrefix,
+ assistant_id,
+ instructions,
+ endpointOption,
+ thread_id: _thread_id,
+ messageId: _messageId,
+ conversationId: convoId,
+ parentMessageId: _parentId = Constants.NO_PARENT,
+ clientTimestamp,
+ } = req.body;
+
+ /** @type {OpenAI} */
+ let openai;
+ /** @type {string|undefined} - the current thread id */
+ let thread_id = _thread_id;
+ /** @type {string|undefined} - the current run id */
+ let run_id;
+ /** @type {string|undefined} - the parent messageId */
+ let parentMessageId = _parentId;
+ /** @type {TMessage[]} */
+ let previousMessages = [];
+ /** @type {import('librechat-data-provider').TConversation | null} */
+ let conversation = null;
+ /** @type {string[]} */
+ let file_ids = [];
+ /** @type {Set} */
+ let attachedFileIds = new Set();
+ /** @type {TMessage | null} */
+ let requestMessage = null;
+ /** @type {undefined | Promise} */
+ let visionPromise;
+
+ const userMessageId = v4();
+ const responseMessageId = v4();
+
+ /** @type {string} - The conversation UUID - created if undefined */
+ const conversationId = convoId ?? v4();
+
+ const cache = getLogStores(CacheKeys.ABORT_KEYS);
+ const cacheKey = `${req.user.id}:${conversationId}`;
+
+ /** @type {Run | undefined} - The completed run, undefined if incomplete */
+ let completedRun;
+
+ const handleError = async (error) => {
+ const defaultErrorMessage =
+ 'The Assistant run failed to initialize. Try sending a message in a new conversation.';
+ const messageData = {
+ thread_id,
+ assistant_id,
+ conversationId,
+ parentMessageId,
+ sender: 'System',
+ user: req.user.id,
+ shouldSaveMessage: false,
+ messageId: responseMessageId,
+ endpoint,
+ };
+
+ if (error.message === 'Run cancelled') {
+ return res.end();
+ } else if (error.message === 'Request closed' && completedRun) {
+ return;
+ } else if (error.message === 'Request closed') {
+ logger.debug('[/assistants/chat/] Request aborted on close');
+ } 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."
+ : ''
+ }`;
+ return sendResponse(req, res, messageData, errorMessage);
+ } else if (error?.message?.includes('string too long')) {
+ return sendResponse(
+ req,
+ res,
+ messageData,
+ 'Message too long. The Assistants API has a limit of 32,768 characters per message. Please shorten it and try again.',
+ );
+ } else if (error?.message?.includes(ViolationTypes.TOKEN_BALANCE)) {
+ return sendResponse(req, res, messageData, error.message);
+ } else {
+ logger.error('[/assistants/chat/]', error);
+ }
+
+ if (!openai || !thread_id || !run_id) {
+ return sendResponse(req, res, messageData, defaultErrorMessage);
+ }
+
+ await sleep(2000);
+
+ try {
+ const status = await cache.get(cacheKey);
+ if (status === 'cancelled') {
+ logger.debug('[/assistants/chat/] Run already cancelled');
+ return res.end();
+ }
+ await cache.delete(cacheKey);
+ const cancelledRun = await openai.beta.threads.runs.cancel(run_id, { thread_id });
+ logger.debug('[/assistants/chat/] Cancelled run:', cancelledRun);
+ } catch (error) {
+ logger.error('[/assistants/chat/] Error cancelling run', error);
+ }
+
+ await sleep(2000);
+
+ let run;
+ try {
+ run = await openai.beta.threads.runs.retrieve(run_id, { thread_id });
+ await recordUsage({
+ ...run.usage,
+ model: run.model,
+ user: req.user.id,
+ conversationId,
+ });
+ } catch (error) {
+ logger.error('[/assistants/chat/] Error fetching or processing run', error);
+ }
+
+ let finalEvent;
+ try {
+ const runMessages = await checkMessageGaps({
+ openai,
+ run_id,
+ endpoint,
+ thread_id,
+ conversationId,
+ latestMessageId: responseMessageId,
+ });
+
+ const errorContentPart = {
+ text: {
+ value:
+ error?.message ?? 'There was an error processing your request. Please try again later.',
+ },
+ type: ContentTypes.ERROR,
+ };
+
+ if (!Array.isArray(runMessages[runMessages.length - 1]?.content)) {
+ runMessages[runMessages.length - 1].content = [errorContentPart];
+ } else {
+ const contentParts = runMessages[runMessages.length - 1].content;
+ for (let i = 0; i < contentParts.length; i++) {
+ const currentPart = contentParts[i];
+ /** @type {CodeToolCall | RetrievalToolCall | FunctionToolCall | undefined} */
+ const toolCall = currentPart?.[ContentTypes.TOOL_CALL];
+ if (
+ toolCall &&
+ toolCall?.function &&
+ !(toolCall?.function?.output || toolCall?.function?.output?.length)
+ ) {
+ contentParts[i] = {
+ ...currentPart,
+ [ContentTypes.TOOL_CALL]: {
+ ...toolCall,
+ function: {
+ ...toolCall.function,
+ output: 'error processing tool',
+ },
+ },
+ };
+ }
+ }
+ runMessages[runMessages.length - 1].content.push(errorContentPart);
+ }
+
+ finalEvent = {
+ final: true,
+ conversation: await getConvo(req.user.id, conversationId),
+ runMessages,
+ };
+ } catch (error) {
+ logger.error('[/assistants/chat/] Error finalizing error process', error);
+ return sendResponse(req, res, messageData, 'The Assistant run failed');
+ }
+
+ return sendResponse(req, res, finalEvent);
+ };
+
+ try {
+ res.on('close', async () => {
+ if (!completedRun) {
+ await handleError(new Error('Request closed'));
+ }
+ });
+
+ if (convoId && !_thread_id) {
+ completedRun = true;
+ throw new Error('Missing thread_id for existing conversation');
+ }
+
+ if (!assistant_id) {
+ completedRun = true;
+ throw new Error('Missing assistant_id');
+ }
+
+ const checkBalanceBeforeRun = async () => {
+ const balanceConfig = getBalanceConfig(appConfig);
+ if (!balanceConfig?.enabled) {
+ return;
+ }
+ const transactions =
+ (await getTransactions({
+ user: req.user.id,
+ context: 'message',
+ conversationId,
+ })) ?? [];
+
+ const totalPreviousTokens = Math.abs(
+ transactions.reduce((acc, curr) => acc + curr.rawAmount, 0),
+ );
+
+ // TODO: make promptBuffer a config option; buffer for titles, needs buffer for system instructions
+ const promptBuffer = parentMessageId === Constants.NO_PARENT && !_thread_id ? 200 : 0;
+ // 5 is added for labels
+ let promptTokens = (await countTokens(text + (promptPrefix ?? ''))) + 5;
+ promptTokens += totalPreviousTokens + promptBuffer;
+ // Count tokens up to the current context window
+ promptTokens = Math.min(promptTokens, getModelMaxTokens(model));
+
+ await checkBalance({
+ req,
+ res,
+ txData: {
+ model,
+ user: req.user.id,
+ tokenType: 'prompt',
+ amount: promptTokens,
+ },
+ });
+ };
+
+ const { openai: _openai } = await getOpenAIClient({
+ req,
+ res,
+ endpointOption,
+ });
+
+ openai = _openai;
+ await validateAuthor({ req, openai });
+
+ if (previousMessages.length) {
+ parentMessageId = previousMessages[previousMessages.length - 1].messageId;
+ }
+
+ let userMessage = {
+ role: 'user',
+ content: text,
+ metadata: {
+ messageId: userMessageId,
+ },
+ };
+
+ /** @type {CreateRunBody | undefined} */
+ const body = createRunBody({
+ assistant_id,
+ model,
+ promptPrefix,
+ instructions,
+ endpointOption,
+ clientTimestamp,
+ });
+
+ const getRequestFileIds = async () => {
+ let thread_file_ids = [];
+ if (convoId) {
+ const convo = await getConvo(req.user.id, convoId);
+ if (convo && convo.file_ids) {
+ thread_file_ids = convo.file_ids;
+ }
+ }
+
+ file_ids = files.map(({ file_id }) => file_id);
+ if (file_ids.length || thread_file_ids.length) {
+ attachedFileIds = new Set([...file_ids, ...thread_file_ids]);
+ if (endpoint === EModelEndpoint.azureAssistants) {
+ userMessage.attachments = Array.from(attachedFileIds).map((file_id) => ({
+ file_id,
+ tools: [{ type: 'file_search' }],
+ }));
+ } else {
+ userMessage.file_ids = Array.from(attachedFileIds);
+ }
+ }
+ };
+
+ const addVisionPrompt = async () => {
+ if (!endpointOption.attachments) {
+ return;
+ }
+
+ /** @type {MongoFile[]} */
+ const attachments = await endpointOption.attachments;
+ if (attachments && attachments.every((attachment) => checkOpenAIStorage(attachment.source))) {
+ return;
+ }
+
+ const assistant = await openai.beta.assistants.retrieve(assistant_id);
+ const visionToolIndex = assistant.tools.findIndex(
+ (tool) => tool?.function && tool?.function?.name === ImageVisionTool.function.name,
+ );
+
+ if (visionToolIndex === -1) {
+ return;
+ }
+
+ let visionMessage = {
+ role: 'user',
+ content: '',
+ };
+ const { files, image_urls } = await encodeAndFormat(
+ req,
+ attachments,
+ {
+ endpoint: EModelEndpoint.assistants,
+ },
+ VisionModes.generative,
+ );
+ visionMessage.image_urls = image_urls.length ? image_urls : undefined;
+ if (!visionMessage.image_urls?.length) {
+ return;
+ }
+
+ const imageCount = visionMessage.image_urls.length;
+ const plural = imageCount > 1;
+ visionMessage.content = createVisionPrompt(plural);
+ visionMessage = formatMessage({ message: visionMessage, endpoint: EModelEndpoint.openAI });
+
+ visionPromise = openai.chat.completions
+ .create({
+ messages: [visionMessage],
+ max_tokens: 4000,
+ })
+ .catch((error) => {
+ logger.error('[/assistants/chat/] Error creating vision prompt', error);
+ });
+
+ const pluralized = plural ? 's' : '';
+ body.additional_instructions = `${
+ 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}.`;
+
+ return files;
+ };
+
+ /** @type {Promise|undefined} */
+ let userMessagePromise;
+
+ const initializeThread = async () => {
+ /** @type {[ undefined | MongoFile[]]}*/
+ const [processedFiles] = await Promise.all([addVisionPrompt(), getRequestFileIds()]);
+ // TODO: may allow multiple messages to be created beforehand in a future update
+ const initThreadBody = {
+ messages: [userMessage],
+ metadata: {
+ user: req.user.id,
+ conversationId,
+ },
+ };
+
+ if (processedFiles) {
+ for (const file of processedFiles) {
+ if (!checkOpenAIStorage(file.source)) {
+ attachedFileIds.delete(file.file_id);
+ const index = file_ids.indexOf(file.file_id);
+ if (index > -1) {
+ file_ids.splice(index, 1);
+ }
+ }
+ }
+
+ userMessage.file_ids = file_ids;
+ }
+
+ const result = await initThread({ openai, body: initThreadBody, thread_id });
+ thread_id = result.thread_id;
+
+ createOnTextProgress({
+ openai,
+ conversationId,
+ userMessageId,
+ messageId: responseMessageId,
+ thread_id,
+ });
+
+ requestMessage = {
+ user: req.user.id,
+ text,
+ messageId: userMessageId,
+ parentMessageId,
+ // TODO: make sure client sends correct format for `files`, use zod
+ files,
+ file_ids,
+ conversationId,
+ isCreatedByUser: true,
+ assistant_id,
+ thread_id,
+ model: assistant_id,
+ endpoint,
+ };
+
+ previousMessages.push(requestMessage);
+
+ /* asynchronous */
+ userMessagePromise = saveUserMessage(req, { ...requestMessage, model });
+
+ conversation = {
+ conversationId,
+ endpoint,
+ promptPrefix: promptPrefix,
+ instructions: instructions,
+ assistant_id,
+ // model,
+ };
+
+ if (file_ids.length) {
+ conversation.file_ids = file_ids;
+ }
+ };
+
+ const promises = [initializeThread(), checkBalanceBeforeRun()];
+ await Promise.all(promises);
+
+ const sendInitialResponse = () => {
+ sendEvent(res, {
+ sync: true,
+ conversationId,
+ // messages: previousMessages,
+ requestMessage,
+ responseMessage: {
+ user: req.user.id,
+ messageId: openai.responseMessage.messageId,
+ parentMessageId: userMessageId,
+ conversationId,
+ assistant_id,
+ thread_id,
+ model: assistant_id,
+ },
+ });
+ };
+
+ /** @type {RunResponse | typeof StreamRunManager | undefined} */
+ let response;
+
+ const processRun = async (retry = false) => {
+ if (endpoint === EModelEndpoint.azureAssistants) {
+ body.model = openai._options.model;
+ openai.attachedFileIds = attachedFileIds;
+ openai.visionPromise = visionPromise;
+ if (retry) {
+ response = await runAssistant({
+ openai,
+ thread_id,
+ run_id,
+ in_progress: openai.in_progress,
+ });
+ return;
+ }
+
+ /* NOTE:
+ * By default, a Run will use the model and tools configuration specified in Assistant object,
+ * but you can override most of these when creating the Run for added flexibility:
+ */
+ const run = await createRun({
+ openai,
+ thread_id,
+ body,
+ });
+
+ run_id = run.id;
+ await cache.set(cacheKey, `${thread_id}:${run_id}`, Time.TEN_MINUTES);
+ sendInitialResponse();
+
+ // todo: retry logic
+ response = await runAssistant({ openai, thread_id, run_id });
+ return;
+ }
+
+ /** @type {{[AssistantStreamEvents.ThreadRunCreated]: (event: ThreadRunCreated) => Promise}} */
+ const handlers = {
+ [AssistantStreamEvents.ThreadRunCreated]: async (event) => {
+ await cache.set(cacheKey, `${thread_id}:${event.data.id}`, Time.TEN_MINUTES);
+ run_id = event.data.id;
+ sendInitialResponse();
+ },
+ };
+
+ const streamRunManager = new StreamRunManager({
+ req,
+ res,
+ openai,
+ handlers,
+ thread_id,
+ visionPromise,
+ attachedFileIds,
+ responseMessage: openai.responseMessage,
+ // streamOptions: {
+
+ // },
+ });
+
+ await streamRunManager.runAssistant({
+ thread_id,
+ body,
+ });
+
+ response = streamRunManager;
+ };
+
+ await processRun();
+ logger.debug('[/assistants/chat/] response', {
+ run: response.run,
+ steps: response.steps,
+ });
+
+ if (response.run.status === RunStatus.CANCELLED) {
+ logger.debug('[/assistants/chat/] Run cancelled, handled by `abortRun`');
+ return res.end();
+ }
+
+ if (response.run.status === RunStatus.IN_PROGRESS) {
+ processRun(true);
+ }
+
+ completedRun = response.run;
+
+ /** @type {ResponseMessage} */
+ const responseMessage = {
+ ...(response.responseMessage ?? response.finalMessage),
+ parentMessageId: userMessageId,
+ conversationId,
+ user: req.user.id,
+ assistant_id,
+ thread_id,
+ model: assistant_id,
+ endpoint,
+ spec: endpointOption.spec,
+ iconURL: endpointOption.iconURL,
+ };
+
+ sendEvent(res, {
+ final: true,
+ conversation,
+ requestMessage: {
+ parentMessageId,
+ thread_id,
+ },
+ });
+ res.end();
+
+ if (userMessagePromise) {
+ await userMessagePromise;
+ }
+ await saveAssistantMessage(req, { ...responseMessage, model });
+
+ if (parentMessageId === Constants.NO_PARENT && !_thread_id) {
+ addTitle(req, {
+ text,
+ responseText: response.text,
+ conversationId,
+ });
+ }
+
+ await addThreadMetadata({
+ openai,
+ thread_id,
+ messageId: responseMessage.messageId,
+ messages: response.messages,
+ });
+
+ if (!response.run.usage) {
+ await sleep(3000);
+ completedRun = await openai.beta.threads.runs.retrieve(response.run.id, { thread_id });
+ if (completedRun.usage) {
+ await recordUsage({
+ ...completedRun.usage,
+ user: req.user.id,
+ model: completedRun.model ?? model,
+ conversationId,
+ });
+ }
+ } else {
+ await recordUsage({
+ ...response.run.usage,
+ user: req.user.id,
+ model: response.run.model ?? model,
+ conversationId,
+ });
+ }
+ } catch (error) {
+ await handleError(error);
+ }
+};
+
+module.exports = chatV1;
diff --git a/api/server/controllers/assistants/chatV2.js b/api/server/controllers/assistants/chatV2.js
new file mode 100644
index 0000000000..414681d6dc
--- /dev/null
+++ b/api/server/controllers/assistants/chatV2.js
@@ -0,0 +1,489 @@
+const { v4 } = require('uuid');
+const { sleep } = require('@librechat/agents');
+const { logger } = require('@librechat/data-schemas');
+const { sendEvent, getBalanceConfig, getModelMaxTokens, countTokens } = require('@librechat/api');
+const {
+ Time,
+ Constants,
+ RunStatus,
+ CacheKeys,
+ ContentTypes,
+ ToolCallTypes,
+ EModelEndpoint,
+ retrievalMimeTypes,
+ AssistantStreamEvents,
+} = require('librechat-data-provider');
+const {
+ initThread,
+ recordUsage,
+ saveUserMessage,
+ addThreadMetadata,
+ saveAssistantMessage,
+} = require('~/server/services/Threads');
+const { runAssistant, createOnTextProgress } = require('~/server/services/AssistantService');
+const { createErrorHandler } = require('~/server/controllers/assistants/errors');
+const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
+const { createRun, StreamRunManager } = require('~/server/services/Runs');
+const { addTitle } = require('~/server/services/Endpoints/assistants');
+const { createRunBody } = require('~/server/services/createRunBody');
+const { getTransactions } = require('~/models/Transaction');
+const { checkBalance } = require('~/models/balanceMethods');
+const { getConvo } = require('~/models/Conversation');
+const getLogStores = require('~/cache/getLogStores');
+const { getOpenAIClient } = require('./helpers');
+
+/**
+ * @route POST /
+ * @desc Chat with an assistant
+ * @access Public
+ * @param {ServerRequest} req - The request object, containing the request data.
+ * @param {Express.Response} res - The response object, used to send back a response.
+ * @returns {void}
+ */
+const chatV2 = async (req, res) => {
+ logger.debug('[/assistants/chat/] req.body', req.body);
+ const appConfig = req.config;
+
+ /** @type {{files: MongoFile[]}} */
+ const {
+ text,
+ model,
+ endpoint,
+ files = [],
+ promptPrefix,
+ assistant_id,
+ instructions,
+ endpointOption,
+ thread_id: _thread_id,
+ messageId: _messageId,
+ conversationId: convoId,
+ parentMessageId: _parentId = Constants.NO_PARENT,
+ clientTimestamp,
+ } = req.body;
+
+ /** @type {OpenAI} */
+ let openai;
+ /** @type {string|undefined} - the current thread id */
+ let thread_id = _thread_id;
+ /** @type {string|undefined} - the current run id */
+ let run_id;
+ /** @type {string|undefined} - the parent messageId */
+ let parentMessageId = _parentId;
+ /** @type {TMessage[]} */
+ let previousMessages = [];
+ /** @type {import('librechat-data-provider').TConversation | null} */
+ let conversation = null;
+ /** @type {string[]} */
+ let file_ids = [];
+ /** @type {Set} */
+ let attachedFileIds = new Set();
+ /** @type {TMessage | null} */
+ let requestMessage = null;
+
+ const userMessageId = v4();
+ const responseMessageId = v4();
+
+ /** @type {string} - The conversation UUID - created if undefined */
+ const conversationId = convoId ?? v4();
+
+ const cache = getLogStores(CacheKeys.ABORT_KEYS);
+ const cacheKey = `${req.user.id}:${conversationId}`;
+
+ /** @type {Run | undefined} - The completed run, undefined if incomplete */
+ let completedRun;
+
+ const getContext = () => ({
+ openai,
+ run_id,
+ endpoint,
+ cacheKey,
+ thread_id,
+ completedRun,
+ assistant_id,
+ conversationId,
+ parentMessageId,
+ responseMessageId,
+ });
+
+ const handleError = createErrorHandler({ req, res, getContext });
+
+ try {
+ res.on('close', async () => {
+ if (!completedRun) {
+ await handleError(new Error('Request closed'));
+ }
+ });
+
+ if (convoId && !_thread_id) {
+ completedRun = true;
+ throw new Error('Missing thread_id for existing conversation');
+ }
+
+ if (!assistant_id) {
+ completedRun = true;
+ throw new Error('Missing assistant_id');
+ }
+
+ const checkBalanceBeforeRun = async () => {
+ const balanceConfig = getBalanceConfig(appConfig);
+ if (!balanceConfig?.enabled) {
+ return;
+ }
+ const transactions =
+ (await getTransactions({
+ user: req.user.id,
+ context: 'message',
+ conversationId,
+ })) ?? [];
+
+ const totalPreviousTokens = Math.abs(
+ transactions.reduce((acc, curr) => acc + curr.rawAmount, 0),
+ );
+
+ // TODO: make promptBuffer a config option; buffer for titles, needs buffer for system instructions
+ const promptBuffer = parentMessageId === Constants.NO_PARENT && !_thread_id ? 200 : 0;
+ // 5 is added for labels
+ let promptTokens = (await countTokens(text + (promptPrefix ?? ''))) + 5;
+ promptTokens += totalPreviousTokens + promptBuffer;
+ // Count tokens up to the current context window
+ promptTokens = Math.min(promptTokens, getModelMaxTokens(model));
+
+ await checkBalance({
+ req,
+ res,
+ txData: {
+ model,
+ user: req.user.id,
+ tokenType: 'prompt',
+ amount: promptTokens,
+ },
+ });
+ };
+
+ const { openai: _openai } = await getOpenAIClient({
+ req,
+ res,
+ endpointOption,
+ });
+
+ openai = _openai;
+ await validateAuthor({ req, openai });
+
+ if (previousMessages.length) {
+ parentMessageId = previousMessages[previousMessages.length - 1].messageId;
+ }
+
+ let userMessage = {
+ role: 'user',
+ content: [
+ {
+ type: ContentTypes.TEXT,
+ text,
+ },
+ ],
+ metadata: {
+ messageId: userMessageId,
+ },
+ };
+
+ /** @type {CreateRunBody | undefined} */
+ const body = createRunBody({
+ assistant_id,
+ model,
+ promptPrefix,
+ instructions,
+ endpointOption,
+ clientTimestamp,
+ });
+
+ const getRequestFileIds = async () => {
+ let thread_file_ids = [];
+ if (convoId) {
+ const convo = await getConvo(req.user.id, convoId);
+ if (convo && convo.file_ids) {
+ thread_file_ids = convo.file_ids;
+ }
+ }
+
+ if (files.length || thread_file_ids.length) {
+ attachedFileIds = new Set([...file_ids, ...thread_file_ids]);
+
+ let attachmentIndex = 0;
+ for (const file of files) {
+ file_ids.push(file.file_id);
+ if (file.type.startsWith('image')) {
+ userMessage.content.push({
+ type: ContentTypes.IMAGE_FILE,
+ [ContentTypes.IMAGE_FILE]: { file_id: file.file_id },
+ });
+ }
+
+ if (!userMessage.attachments) {
+ userMessage.attachments = [];
+ }
+
+ userMessage.attachments.push({
+ file_id: file.file_id,
+ tools: [{ type: ToolCallTypes.CODE_INTERPRETER }],
+ });
+
+ if (file.type.startsWith('image')) {
+ continue;
+ }
+
+ const mimeType = file.type;
+ const isSupportedByRetrieval = retrievalMimeTypes.some((regex) => regex.test(mimeType));
+ if (isSupportedByRetrieval) {
+ userMessage.attachments[attachmentIndex].tools.push({
+ type: ToolCallTypes.FILE_SEARCH,
+ });
+ }
+
+ attachmentIndex++;
+ }
+ }
+ };
+
+ /** @type {Promise|undefined} */
+ let userMessagePromise;
+
+ const initializeThread = async () => {
+ await getRequestFileIds();
+
+ // TODO: may allow multiple messages to be created beforehand in a future update
+ const initThreadBody = {
+ messages: [userMessage],
+ metadata: {
+ user: req.user.id,
+ conversationId,
+ },
+ };
+
+ const result = await initThread({ openai, body: initThreadBody, thread_id });
+ thread_id = result.thread_id;
+
+ createOnTextProgress({
+ openai,
+ conversationId,
+ userMessageId,
+ messageId: responseMessageId,
+ thread_id,
+ });
+
+ requestMessage = {
+ user: req.user.id,
+ text,
+ messageId: userMessageId,
+ parentMessageId,
+ // TODO: make sure client sends correct format for `files`, use zod
+ files,
+ file_ids,
+ conversationId,
+ isCreatedByUser: true,
+ assistant_id,
+ thread_id,
+ model: assistant_id,
+ endpoint,
+ };
+
+ previousMessages.push(requestMessage);
+
+ /* asynchronous */
+ userMessagePromise = saveUserMessage(req, { ...requestMessage, model });
+
+ conversation = {
+ conversationId,
+ endpoint,
+ promptPrefix: promptPrefix,
+ instructions: instructions,
+ assistant_id,
+ // model,
+ };
+
+ if (file_ids.length) {
+ conversation.file_ids = file_ids;
+ }
+ };
+
+ const promises = [initializeThread(), checkBalanceBeforeRun()];
+ await Promise.all(promises);
+
+ const sendInitialResponse = () => {
+ sendEvent(res, {
+ sync: true,
+ conversationId,
+ // messages: previousMessages,
+ requestMessage,
+ responseMessage: {
+ user: req.user.id,
+ messageId: openai.responseMessage.messageId,
+ parentMessageId: userMessageId,
+ conversationId,
+ assistant_id,
+ thread_id,
+ model: assistant_id,
+ },
+ });
+ };
+
+ /** @type {RunResponse | typeof StreamRunManager | undefined} */
+ let response;
+
+ const processRun = async (retry = false) => {
+ if (endpoint === EModelEndpoint.azureAssistants) {
+ body.model = openai._options.model;
+ openai.attachedFileIds = attachedFileIds;
+ if (retry) {
+ response = await runAssistant({
+ openai,
+ thread_id,
+ run_id,
+ in_progress: openai.in_progress,
+ });
+ return;
+ }
+
+ /* NOTE:
+ * By default, a Run will use the model and tools configuration specified in Assistant object,
+ * but you can override most of these when creating the Run for added flexibility:
+ */
+ const run = await createRun({
+ openai,
+ thread_id,
+ body,
+ });
+
+ run_id = run.id;
+ await cache.set(cacheKey, `${thread_id}:${run_id}`, Time.TEN_MINUTES);
+ sendInitialResponse();
+
+ // todo: retry logic
+ response = await runAssistant({ openai, thread_id, run_id });
+ return;
+ }
+
+ /** @type {{[AssistantStreamEvents.ThreadRunCreated]: (event: ThreadRunCreated) => Promise}} */
+ const handlers = {
+ [AssistantStreamEvents.ThreadRunCreated]: async (event) => {
+ await cache.set(cacheKey, `${thread_id}:${event.data.id}`, Time.TEN_MINUTES);
+ run_id = event.data.id;
+ sendInitialResponse();
+ },
+ };
+
+ /** @type {undefined | TAssistantEndpoint} */
+ const config = appConfig.endpoints?.[endpoint] ?? {};
+ /** @type {undefined | TBaseEndpoint} */
+ const allConfig = appConfig.endpoints?.all;
+
+ const streamRunManager = new StreamRunManager({
+ req,
+ res,
+ openai,
+ handlers,
+ thread_id,
+ attachedFileIds,
+ parentMessageId: userMessageId,
+ responseMessage: openai.responseMessage,
+ streamRate: allConfig?.streamRate ?? config.streamRate,
+ // streamOptions: {
+
+ // },
+ });
+
+ await streamRunManager.runAssistant({
+ thread_id,
+ body,
+ });
+
+ response = streamRunManager;
+ response.text = streamRunManager.intermediateText;
+ };
+
+ await processRun();
+ logger.debug('[/assistants/chat/] response', {
+ run: response.run,
+ steps: response.steps,
+ });
+
+ if (response.run.status === RunStatus.CANCELLED) {
+ logger.debug('[/assistants/chat/] Run cancelled, handled by `abortRun`');
+ return res.end();
+ }
+
+ if (response.run.status === RunStatus.IN_PROGRESS) {
+ processRun(true);
+ }
+
+ completedRun = response.run;
+
+ /** @type {ResponseMessage} */
+ const responseMessage = {
+ ...(response.responseMessage ?? response.finalMessage),
+ text: response.text,
+ parentMessageId: userMessageId,
+ conversationId,
+ user: req.user.id,
+ assistant_id,
+ thread_id,
+ model: assistant_id,
+ endpoint,
+ spec: endpointOption.spec,
+ iconURL: endpointOption.iconURL,
+ };
+
+ sendEvent(res, {
+ final: true,
+ conversation,
+ requestMessage: {
+ parentMessageId,
+ thread_id,
+ },
+ });
+ res.end();
+
+ if (userMessagePromise) {
+ await userMessagePromise;
+ }
+ await saveAssistantMessage(req, { ...responseMessage, model });
+
+ if (parentMessageId === Constants.NO_PARENT && !_thread_id) {
+ addTitle(req, {
+ text,
+ responseText: response.text,
+ conversationId,
+ });
+ }
+
+ await addThreadMetadata({
+ openai,
+ thread_id,
+ messageId: responseMessage.messageId,
+ messages: response.messages,
+ });
+
+ if (!response.run.usage) {
+ await sleep(3000);
+ completedRun = await openai.beta.threads.runs.retrieve(response.run.id, { thread_id });
+ if (completedRun.usage) {
+ await recordUsage({
+ ...completedRun.usage,
+ user: req.user.id,
+ model: completedRun.model ?? model,
+ conversationId,
+ });
+ }
+ } else {
+ await recordUsage({
+ ...response.run.usage,
+ user: req.user.id,
+ model: response.run.model ?? model,
+ conversationId,
+ });
+ }
+ } catch (error) {
+ await handleError(error);
+ }
+};
+
+module.exports = chatV2;
diff --git a/api/server/controllers/assistants/errors.js b/api/server/controllers/assistants/errors.js
new file mode 100644
index 0000000000..1ae12ea3d5
--- /dev/null
+++ b/api/server/controllers/assistants/errors.js
@@ -0,0 +1,193 @@
+// errorHandler.js
+const { logger } = require('@librechat/data-schemas');
+const { CacheKeys, ViolationTypes, ContentTypes } = require('librechat-data-provider');
+const { recordUsage, checkMessageGaps } = require('~/server/services/Threads');
+const { sendResponse } = require('~/server/middleware/error');
+const { getConvo } = require('~/models/Conversation');
+const getLogStores = require('~/cache/getLogStores');
+
+/**
+ * @typedef {Object} ErrorHandlerContext
+ * @property {OpenAIClient} openai - The OpenAI client
+ * @property {string} thread_id - The thread ID
+ * @property {string} run_id - The run ID
+ * @property {boolean} completedRun - Whether the run has completed
+ * @property {string} assistant_id - The assistant ID
+ * @property {string} conversationId - The conversation ID
+ * @property {string} parentMessageId - The parent message ID
+ * @property {string} responseMessageId - The response message ID
+ * @property {string} endpoint - The endpoint being used
+ * @property {string} cacheKey - The cache key for the current request
+ */
+
+/**
+ * @typedef {Object} ErrorHandlerDependencies
+ * @property {ServerRequest} req - The Express request object
+ * @property {Express.Response} res - The Express response object
+ * @property {() => ErrorHandlerContext} getContext - Function to get the current context
+ * @property {string} [originPath] - The origin path for the error handler
+ */
+
+/**
+ * Creates an error handler function with the given dependencies
+ * @param {ErrorHandlerDependencies} dependencies - The dependencies for the error handler
+ * @returns {(error: Error) => Promise} The error handler function
+ */
+const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/chat/' }) => {
+ const cache = getLogStores(CacheKeys.ABORT_KEYS);
+
+ /**
+ * Handles errors that occur during the chat process
+ * @param {Error} error - The error that occurred
+ * @returns {Promise}
+ */
+ return async (error) => {
+ const {
+ openai,
+ run_id,
+ endpoint,
+ cacheKey,
+ thread_id,
+ completedRun,
+ assistant_id,
+ conversationId,
+ parentMessageId,
+ responseMessageId,
+ } = getContext();
+
+ const defaultErrorMessage =
+ 'The Assistant run failed to initialize. Try sending a message in a new conversation.';
+ const messageData = {
+ thread_id,
+ assistant_id,
+ conversationId,
+ parentMessageId,
+ sender: 'System',
+ user: req.user.id,
+ shouldSaveMessage: false,
+ messageId: responseMessageId,
+ endpoint,
+ };
+
+ if (error.message === 'Run cancelled') {
+ return res.end();
+ } else if (error.message === 'Request closed' && completedRun) {
+ return;
+ } else if (error.message === 'Request closed') {
+ logger.debug(`[${originPath}] Request aborted on close`);
+ } else if (/Files.*are invalid/.test(error.message)) {
+ const errorMessage = `Files are invalid, or may not have uploaded yet.${
+ endpoint === 'azureAssistants'
+ ? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
+ : ''
+ }`;
+ return sendResponse(req, res, messageData, errorMessage);
+ } else if (error?.message?.includes('string too long')) {
+ return sendResponse(
+ req,
+ res,
+ messageData,
+ 'Message too long. The Assistants API has a limit of 32,768 characters per message. Please shorten it and try again.',
+ );
+ } else if (error?.message?.includes(ViolationTypes.TOKEN_BALANCE)) {
+ return sendResponse(req, res, messageData, error.message);
+ } else {
+ logger.error(`[${originPath}]`, error);
+ }
+
+ if (!openai || !thread_id || !run_id) {
+ return sendResponse(req, res, messageData, defaultErrorMessage);
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ try {
+ const status = await cache.get(cacheKey);
+ if (status === 'cancelled') {
+ logger.debug(`[${originPath}] Run already cancelled`);
+ return res.end();
+ }
+ await cache.delete(cacheKey);
+ const cancelledRun = await openai.beta.threads.runs.cancel(run_id, { thread_id });
+ logger.debug(`[${originPath}] Cancelled run:`, cancelledRun);
+ } catch (error) {
+ logger.error(`[${originPath}] Error cancelling run`, error);
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ let run;
+ try {
+ run = await openai.beta.threads.runs.retrieve(run_id, { thread_id });
+ await recordUsage({
+ ...run.usage,
+ model: run.model,
+ user: req.user.id,
+ conversationId,
+ });
+ } catch (error) {
+ logger.error(`[${originPath}] Error fetching or processing run`, error);
+ }
+
+ let finalEvent;
+ try {
+ const runMessages = await checkMessageGaps({
+ openai,
+ run_id,
+ endpoint,
+ thread_id,
+ conversationId,
+ latestMessageId: responseMessageId,
+ });
+
+ const errorContentPart = {
+ text: {
+ value:
+ error?.message ?? 'There was an error processing your request. Please try again later.',
+ },
+ type: ContentTypes.ERROR,
+ };
+
+ if (!Array.isArray(runMessages[runMessages.length - 1]?.content)) {
+ runMessages[runMessages.length - 1].content = [errorContentPart];
+ } else {
+ const contentParts = runMessages[runMessages.length - 1].content;
+ for (let i = 0; i < contentParts.length; i++) {
+ const currentPart = contentParts[i];
+ /** @type {CodeToolCall | RetrievalToolCall | FunctionToolCall | undefined} */
+ const toolCall = currentPart?.[ContentTypes.TOOL_CALL];
+ if (
+ toolCall &&
+ toolCall?.function &&
+ !(toolCall?.function?.output || toolCall?.function?.output?.length)
+ ) {
+ contentParts[i] = {
+ ...currentPart,
+ [ContentTypes.TOOL_CALL]: {
+ ...toolCall,
+ function: {
+ ...toolCall.function,
+ output: 'error processing tool',
+ },
+ },
+ };
+ }
+ }
+ runMessages[runMessages.length - 1].content.push(errorContentPart);
+ }
+
+ finalEvent = {
+ final: true,
+ conversation: await getConvo(req.user.id, conversationId),
+ runMessages,
+ };
+ } catch (error) {
+ logger.error(`[${originPath}] Error finalizing error process`, error);
+ return sendResponse(req, res, messageData, 'The Assistant run failed');
+ }
+
+ return sendResponse(req, res, finalEvent);
+ };
+};
+
+module.exports = { createErrorHandler };
diff --git a/api/server/controllers/assistants/helpers.js b/api/server/controllers/assistants/helpers.js
new file mode 100644
index 0000000000..9183680f1e
--- /dev/null
+++ b/api/server/controllers/assistants/helpers.js
@@ -0,0 +1,278 @@
+const {
+ SystemRoles,
+ EModelEndpoint,
+ defaultOrderQuery,
+ defaultAssistantsVersion,
+} = require('librechat-data-provider');
+const {
+ initializeClient: initAzureClient,
+} = require('~/server/services/Endpoints/azureAssistants');
+const { initializeClient } = require('~/server/services/Endpoints/assistants');
+const { getEndpointsConfig } = require('~/server/services/Config');
+
+/**
+ * @param {ServerRequest} req
+ * @param {string} [endpoint]
+ * @returns {Promise}
+ */
+const getCurrentVersion = async (req, endpoint) => {
+ const index = req.baseUrl.lastIndexOf('/v');
+ let version = index !== -1 ? req.baseUrl.substring(index + 1, index + 3) : null;
+ if (!version && req.body.version) {
+ version = `v${req.body.version}`;
+ }
+ if (!version && endpoint) {
+ const endpointsConfig = await getEndpointsConfig(req);
+ version = `v${endpointsConfig?.[endpoint]?.version ?? defaultAssistantsVersion[endpoint]}`;
+ }
+ if (!version?.startsWith('v') && version.length !== 2) {
+ throw new Error(`[${req.baseUrl}] Invalid version: ${version}`);
+ }
+ return version;
+};
+
+/**
+ * Asynchronously lists assistants based on provided query parameters.
+ *
+ * Initializes the client with the current request and response objects and lists assistants
+ * according to the query parameters. This function abstracts the logic for non-Azure paths.
+ *
+ * @deprecated
+ * @async
+ * @param {object} params - The parameters object.
+ * @param {object} params.req - The request object, used for initializing the client.
+ * @param {object} params.res - The response object, used for initializing the client.
+ * @param {string} params.version - The API version to use.
+ * @param {object} params.query - The query parameters to list assistants (e.g., limit, order).
+ * @returns {Promise} A promise that resolves to the response from the `openai.beta.assistants.list` method call.
+ */
+const _listAssistants = async ({ req, res, version, query }) => {
+ const { openai } = await getOpenAIClient({ req, res, version });
+ return openai.beta.assistants.list(query);
+};
+
+/**
+ * Fetches all assistants based on provided query params, until `has_more` is `false`.
+ *
+ * @async
+ * @param {object} params - The parameters object.
+ * @param {object} params.req - The request object, used for initializing the client.
+ * @param {object} params.res - The response object, used for initializing the client.
+ * @param {string} params.version - The API version to use.
+ * @param {Omit} params.query - The query parameters to list assistants (e.g., limit, order).
+ * @returns {Promise>} A promise that resolves to the response from the `openai.beta.assistants.list` method call.
+ */
+const listAllAssistants = async ({ req, res, version, query }) => {
+ /** @type {{ openai: OpenAI }} */
+ const { openai } = await getOpenAIClient({ req, res, version });
+ const allAssistants = [];
+
+ let first_id;
+ let last_id;
+ let afterToken = query.after;
+ let hasMore = true;
+
+ while (hasMore) {
+ const response = await openai.beta.assistants.list({
+ ...query,
+ after: afterToken,
+ });
+
+ const { body } = response;
+
+ allAssistants.push(...body.data);
+ hasMore = body.has_more;
+
+ if (!first_id) {
+ first_id = body.first_id;
+ }
+
+ if (hasMore) {
+ afterToken = body.last_id;
+ } else {
+ last_id = body.last_id;
+ }
+ }
+
+ return {
+ data: allAssistants,
+ body: {
+ data: allAssistants,
+ has_more: false,
+ first_id,
+ last_id,
+ },
+ };
+};
+
+/**
+ * Asynchronously lists assistants for Azure configured groups.
+ *
+ * Iterates through Azure configured assistant groups, initializes the client with the current request and response objects,
+ * lists assistants based on the provided query parameters, and merges their data alongside the model information into a single array.
+ *
+ * @async
+ * @param {object} params - The parameters object.
+ * @param {object} params.req - The request object, used for initializing the client and manipulating the request body.
+ * @param {object} params.res - The response object, used for initializing the client.
+ * @param {string} params.version - The API version to use.
+ * @param {TAzureConfig} params.azureConfig - The Azure configuration object containing assistantGroups and groupMap.
+ * @param {object} params.query - The query parameters to list assistants (e.g., limit, order).
+ * @returns {Promise} A promise that resolves to an array of assistant data merged with their respective model information.
+ */
+const listAssistantsForAzure = async ({ req, res, version, azureConfig = {}, query }) => {
+ /** @type {Array<[string, TAzureModelConfig]>} */
+ const groupModelTuples = [];
+ const promises = [];
+ /** @type {Array} */
+ const groups = [];
+
+ const { groupMap, assistantGroups } = azureConfig;
+
+ for (const groupName of assistantGroups) {
+ const group = groupMap[groupName];
+ groups.push(group);
+
+ const currentModelTuples = Object.entries(group?.models);
+ groupModelTuples.push(currentModelTuples);
+
+ /* The specified model is only necessary to
+ fetch assistants for the shared instance */
+ req.body = req.body || {}; // Express 5: req.body is undefined instead of {} when no body parser runs
+ req.body.model = currentModelTuples[0][0];
+ promises.push(listAllAssistants({ req, res, version, query }));
+ }
+
+ const resolvedQueries = await Promise.all(promises);
+ const data = resolvedQueries.flatMap((res, i) =>
+ res.data.map((assistant) => {
+ const deploymentName = assistant.model;
+ const currentGroup = groups[i];
+ const currentModelTuples = groupModelTuples[i];
+ const firstModel = currentModelTuples[0][0];
+
+ if (currentGroup.deploymentName === deploymentName) {
+ return { ...assistant, model: firstModel };
+ }
+
+ for (const [model, modelConfig] of currentModelTuples) {
+ if (modelConfig.deploymentName === deploymentName) {
+ return { ...assistant, model };
+ }
+ }
+
+ return { ...assistant, model: firstModel };
+ }),
+ );
+
+ return {
+ first_id: data[0]?.id,
+ last_id: data[data.length - 1]?.id,
+ object: 'list',
+ has_more: false,
+ data,
+ };
+};
+
+/**
+ * Initializes the OpenAI client.
+ * @param {object} params - The parameters object.
+ * @param {ServerRequest} params.req - The request object.
+ * @param {ServerResponse} params.res - The response object.
+ * @param {TEndpointOption} params.endpointOption - The endpoint options.
+ * @param {boolean} params.initAppClient - Whether to initialize the app client.
+ * @param {string} params.overrideEndpoint - The endpoint to override.
+ * @returns {Promise<{ openai: OpenAI, openAIApiKey: string }>} - The initialized OpenAI SDK client.
+ */
+async function getOpenAIClient({ req, res, endpointOption, initAppClient, overrideEndpoint }) {
+ let endpoint = overrideEndpoint ?? req.body?.endpoint ?? req.query?.endpoint;
+ const version = await getCurrentVersion(req, endpoint);
+ if (!endpoint) {
+ throw new Error(`[${req.baseUrl}] Endpoint is required`);
+ }
+
+ let result;
+ if (endpoint === EModelEndpoint.assistants) {
+ result = await initializeClient({ req, res, version, endpointOption, initAppClient });
+ } else if (endpoint === EModelEndpoint.azureAssistants) {
+ result = await initAzureClient({ req, res, version, endpointOption, initAppClient });
+ }
+
+ return result;
+}
+
+/**
+ * Returns a list of assistants.
+ * @param {object} params
+ * @param {object} params.req - Express Request
+ * @param {AssistantListParams} [params.req.query] - The assistant list parameters for pagination and sorting.
+ * @param {object} params.res - Express Response
+ * @param {string} [params.overrideEndpoint] - The endpoint to override the request endpoint.
+ * @returns {Promise} 200 - success response - application/json
+ */
+const fetchAssistants = async ({ req, res, overrideEndpoint }) => {
+ const appConfig = req.config;
+ const {
+ limit = 100,
+ order = 'desc',
+ after,
+ before,
+ endpoint,
+ } = req.query ?? {
+ endpoint: overrideEndpoint,
+ ...defaultOrderQuery,
+ };
+
+ const version = await getCurrentVersion(req, endpoint);
+ const query = { limit, order, after, before };
+
+ /** @type {AssistantListResponse} */
+ let body;
+
+ if (endpoint === EModelEndpoint.assistants) {
+ ({ body } = await listAllAssistants({ req, res, version, query }));
+ } else if (endpoint === EModelEndpoint.azureAssistants) {
+ const azureConfig = appConfig.endpoints?.[EModelEndpoint.azureOpenAI];
+ body = await listAssistantsForAzure({ req, res, version, azureConfig, query });
+ }
+
+ if (req.user.role === SystemRoles.ADMIN) {
+ return body;
+ } else if (!appConfig.endpoints?.[endpoint]) {
+ return body;
+ }
+
+ body.data = filterAssistants({
+ userId: req.user.id,
+ assistants: body.data,
+ assistantsConfig: appConfig.endpoints?.[endpoint],
+ });
+ return body;
+};
+
+/**
+ * Filter assistants based on configuration.
+ *
+ * @param {object} params - The parameters object.
+ * @param {string} params.userId - The user ID to filter private assistants.
+ * @param {Assistant[]} params.assistants - The list of assistants to filter.
+ * @param {Partial} params.assistantsConfig - The assistant configuration.
+ * @returns {Assistant[]} - The filtered list of assistants.
+ */
+function filterAssistants({ assistants, userId, assistantsConfig }) {
+ const { supportedIds, excludedIds, privateAssistants } = assistantsConfig;
+ if (privateAssistants) {
+ return assistants.filter((assistant) => userId === assistant.metadata?.author);
+ } else if (supportedIds?.length) {
+ return assistants.filter((assistant) => supportedIds.includes(assistant.id));
+ } else if (excludedIds?.length) {
+ return assistants.filter((assistant) => !excludedIds.includes(assistant.id));
+ }
+ return assistants;
+}
+
+module.exports = {
+ getOpenAIClient,
+ fetchAssistants,
+ getCurrentVersion,
+};
diff --git a/api/server/controllers/assistants/v1.js b/api/server/controllers/assistants/v1.js
new file mode 100644
index 0000000000..32842deb0f
--- /dev/null
+++ b/api/server/controllers/assistants/v1.js
@@ -0,0 +1,384 @@
+const fs = require('fs').promises;
+const { logger } = require('@librechat/data-schemas');
+const { FileContext } = require('librechat-data-provider');
+const { uploadImageBuffer, filterFile } = require('~/server/services/Files/process');
+const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+const { deleteAssistantActions } = require('~/server/services/ActionService');
+const { updateAssistantDoc, getAssistants } = require('~/models/Assistant');
+const { getOpenAIClient, fetchAssistants } = require('./helpers');
+const { getCachedTools } = require('~/server/services/Config');
+const { manifestToolMap } = require('~/app/clients/tools');
+const { deleteFileByFilter } = require('~/models');
+
+/**
+ * Create an assistant.
+ * @route POST /assistants
+ * @param {AssistantCreateParams} req.body - The assistant creation parameters.
+ * @returns {Assistant} 201 - success response - application/json
+ */
+const createAssistant = async (req, res) => {
+ try {
+ const { openai } = await getOpenAIClient({ req, res });
+
+ const {
+ tools = [],
+ endpoint,
+ conversation_starters,
+ append_current_datetime,
+ ...assistantData
+ } = req.body;
+ delete assistantData.conversation_starters;
+ delete assistantData.append_current_datetime;
+
+ const toolDefinitions = await getCachedTools();
+
+ assistantData.tools = tools
+ .map((tool) => {
+ if (typeof tool !== 'string') {
+ return tool;
+ }
+
+ const toolDef = toolDefinitions[tool];
+ if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
+ return Object.entries(toolDefinitions)
+ .filter(([key]) => key.startsWith(`${tool}_`))
+
+ .map(([_, val]) => val);
+ }
+
+ return toolDef;
+ })
+ .filter((tool) => tool)
+ .flat();
+
+ let azureModelIdentifier = null;
+ if (openai.locals?.azureOptions) {
+ azureModelIdentifier = assistantData.model;
+ assistantData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
+ }
+
+ assistantData.metadata = {
+ author: req.user.id,
+ endpoint,
+ };
+
+ const assistant = await openai.beta.assistants.create(assistantData);
+
+ const createData = { user: req.user.id };
+ if (conversation_starters) {
+ createData.conversation_starters = conversation_starters;
+ }
+ if (append_current_datetime !== undefined) {
+ createData.append_current_datetime = append_current_datetime;
+ }
+
+ const document = await updateAssistantDoc({ assistant_id: assistant.id }, createData);
+
+ if (azureModelIdentifier) {
+ assistant.model = azureModelIdentifier;
+ }
+
+ if (document.conversation_starters) {
+ assistant.conversation_starters = document.conversation_starters;
+ }
+
+ if (append_current_datetime !== undefined) {
+ assistant.append_current_datetime = append_current_datetime;
+ }
+
+ logger.debug('/assistants/', assistant);
+ res.status(201).json(assistant);
+ } catch (error) {
+ logger.error('[/assistants] Error creating assistant', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Retrieves an assistant.
+ * @route GET /assistants/:id
+ * @param {string} req.params.id - Assistant identifier.
+ * @returns {Assistant} 200 - success response - application/json
+ */
+const retrieveAssistant = async (req, res) => {
+ try {
+ /* NOTE: not actually being used right now */
+ const { openai } = await getOpenAIClient({ req, res });
+ const assistant_id = req.params.id;
+ const assistant = await openai.beta.assistants.retrieve(assistant_id);
+ res.json(assistant);
+ } catch (error) {
+ logger.error('[/assistants/:id] Error retrieving assistant', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Modifies an assistant.
+ * @route PATCH /assistants/:id
+ * @param {object} req - Express Request
+ * @param {object} req.params - Request params
+ * @param {string} req.params.id - Assistant identifier.
+ * @param {AssistantUpdateParams} req.body - The assistant update parameters.
+ * @returns {Assistant} 200 - success response - application/json
+ */
+const patchAssistant = async (req, res) => {
+ try {
+ const { openai } = await getOpenAIClient({ req, res });
+ await validateAuthor({ req, openai });
+
+ const assistant_id = req.params.id;
+ const {
+ endpoint: _e,
+ conversation_starters,
+ append_current_datetime,
+ ...updateData
+ } = req.body;
+
+ const toolDefinitions = await getCachedTools();
+
+ updateData.tools = (updateData.tools ?? [])
+ .map((tool) => {
+ if (typeof tool !== 'string') {
+ return tool;
+ }
+
+ const toolDef = toolDefinitions[tool];
+ if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
+ return Object.entries(toolDefinitions)
+ .filter(([key]) => key.startsWith(`${tool}_`))
+
+ .map(([_, val]) => val);
+ }
+
+ return toolDef;
+ })
+ .filter((tool) => tool)
+ .flat();
+
+ if (openai.locals?.azureOptions && updateData.model) {
+ updateData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
+ }
+
+ const updatedAssistant = await openai.beta.assistants.update(assistant_id, updateData);
+
+ if (conversation_starters !== undefined) {
+ const conversationStartersUpdate = await updateAssistantDoc(
+ { assistant_id },
+ { conversation_starters },
+ );
+ updatedAssistant.conversation_starters = conversationStartersUpdate.conversation_starters;
+ }
+
+ if (append_current_datetime !== undefined) {
+ await updateAssistantDoc({ assistant_id }, { append_current_datetime });
+ updatedAssistant.append_current_datetime = append_current_datetime;
+ }
+
+ res.json(updatedAssistant);
+ } catch (error) {
+ logger.error('[/assistants/:id] Error updating assistant', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Deletes an assistant.
+ * @route DELETE /assistants/:id
+ * @param {object} req - Express Request
+ * @param {object} req.params - Request params
+ * @param {string} req.params.id - Assistant identifier.
+ * @returns {Assistant} 200 - success response - application/json
+ */
+const deleteAssistant = async (req, res) => {
+ try {
+ const { openai } = await getOpenAIClient({ req, res });
+ await validateAuthor({ req, openai });
+
+ const assistant_id = req.params.id;
+ const deletionStatus = await openai.beta.assistants.delete(assistant_id);
+ if (deletionStatus?.deleted) {
+ await deleteAssistantActions({ req, assistant_id });
+ }
+ res.json(deletionStatus);
+ } catch (error) {
+ logger.error('[/assistants/:id] Error deleting assistant', error);
+ res.status(500).json({ error: 'Error deleting assistant' });
+ }
+};
+
+/**
+ * Returns a list of assistants.
+ * @route GET /assistants
+ * @param {object} req - Express Request
+ * @param {AssistantListParams} req.query - The assistant list parameters for pagination and sorting.
+ * @returns {AssistantListResponse} 200 - success response - application/json
+ */
+const listAssistants = async (req, res) => {
+ try {
+ const body = await fetchAssistants({ req, res });
+ res.json(body);
+ } catch (error) {
+ logger.error('[/assistants] Error listing assistants', error);
+ res.status(500).json({ message: 'Error listing assistants' });
+ }
+};
+
+/**
+ * Filter assistants based on configuration.
+ *
+ * @param {object} params - The parameters object.
+ * @param {string} params.userId - The user ID to filter private assistants.
+ * @param {AssistantDocument[]} params.assistants - The list of assistants to filter.
+ * @param {Partial} [params.assistantsConfig] - The assistant configuration.
+ * @returns {AssistantDocument[]} - The filtered list of assistants.
+ */
+function filterAssistantDocs({ documents, userId, assistantsConfig = {} }) {
+ const { supportedIds, excludedIds, privateAssistants } = assistantsConfig;
+ const removeUserId = (doc) => {
+ const { user: _u, ...document } = doc;
+ return document;
+ };
+
+ if (privateAssistants) {
+ return documents.filter((doc) => userId === doc.user.toString()).map(removeUserId);
+ } else if (supportedIds?.length) {
+ return documents.filter((doc) => supportedIds.includes(doc.assistant_id)).map(removeUserId);
+ } else if (excludedIds?.length) {
+ return documents.filter((doc) => !excludedIds.includes(doc.assistant_id)).map(removeUserId);
+ }
+ return documents.map(removeUserId);
+}
+
+/**
+ * Returns a list of the user's assistant documents (metadata saved to database).
+ * @route GET /assistants/documents
+ * @returns {AssistantDocument[]} 200 - success response - application/json
+ */
+const getAssistantDocuments = async (req, res) => {
+ try {
+ const appConfig = req.config;
+ const endpoint = req.query?.endpoint;
+ const assistantsConfig = appConfig.endpoints?.[endpoint];
+ const documents = await getAssistants(
+ {},
+ {
+ user: 1,
+ assistant_id: 1,
+ conversation_starters: 1,
+ createdAt: 1,
+ updatedAt: 1,
+ append_current_datetime: 1,
+ },
+ );
+
+ const docs = filterAssistantDocs({
+ documents,
+ userId: req.user.id,
+ assistantsConfig,
+ });
+ res.json(docs);
+ } catch (error) {
+ logger.error('[/assistants/documents] Error listing assistant documents', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Uploads and updates an avatar for a specific assistant.
+ * @route POST /:assistant_id/avatar
+ * @param {object} req - Express Request
+ * @param {object} req.params - Request params
+ * @param {string} req.params.assistant_id - The ID of the assistant.
+ * @param {Express.Multer.File} req.file - The avatar image file.
+ * @param {object} req.body - Request body
+ * @returns {Object} 200 - success response - application/json
+ */
+const uploadAssistantAvatar = async (req, res) => {
+ try {
+ const appConfig = req.config;
+ filterFile({ req, file: req.file, image: true, isAvatar: true });
+ const { assistant_id } = req.params;
+ if (!assistant_id) {
+ return res.status(400).json({ message: 'Assistant ID is required' });
+ }
+
+ const { openai } = await getOpenAIClient({ req, res });
+ await validateAuthor({ req, openai });
+
+ const buffer = await fs.readFile(req.file.path);
+ const image = await uploadImageBuffer({
+ req,
+ context: FileContext.avatar,
+ metadata: { buffer },
+ });
+
+ let _metadata;
+
+ try {
+ const assistant = await openai.beta.assistants.retrieve(assistant_id);
+ if (assistant) {
+ _metadata = assistant.metadata;
+ }
+ } catch (error) {
+ logger.error('[/:assistant_id/avatar] Error fetching assistant', error);
+ _metadata = {};
+ }
+
+ if (_metadata.avatar && _metadata.avatar_source) {
+ const { deleteFile } = getStrategyFunctions(_metadata.avatar_source);
+ try {
+ await deleteFile(req, { filepath: _metadata.avatar });
+ await deleteFileByFilter({ user: req.user.id, filepath: _metadata.avatar });
+ } catch (error) {
+ logger.error('[/:assistant_id/avatar] Error deleting old avatar', error);
+ }
+ }
+
+ const metadata = {
+ ..._metadata,
+ avatar: image.filepath,
+ avatar_source: appConfig.fileStrategy,
+ };
+
+ const promises = [];
+ promises.push(
+ updateAssistantDoc(
+ { assistant_id },
+ {
+ avatar: {
+ filepath: image.filepath,
+ source: appConfig.fileStrategy,
+ },
+ user: req.user.id,
+ },
+ ),
+ );
+ promises.push(openai.beta.assistants.update(assistant_id, { metadata }));
+
+ const resolved = await Promise.all(promises);
+ res.status(201).json(resolved[1]);
+ } catch (error) {
+ const message = 'An error occurred while updating the Assistant Avatar';
+ logger.error(message, error);
+ res.status(500).json({ message });
+ } finally {
+ try {
+ await fs.unlink(req.file.path);
+ logger.debug('[/:agent_id/avatar] Temp. image upload file deleted');
+ } catch {
+ logger.debug('[/:agent_id/avatar] Temp. image upload file already deleted');
+ }
+ }
+};
+
+module.exports = {
+ createAssistant,
+ retrieveAssistant,
+ patchAssistant,
+ deleteAssistant,
+ listAssistants,
+ getAssistantDocuments,
+ uploadAssistantAvatar,
+};
diff --git a/api/server/controllers/assistants/v2.js b/api/server/controllers/assistants/v2.js
new file mode 100644
index 0000000000..278dd13021
--- /dev/null
+++ b/api/server/controllers/assistants/v2.js
@@ -0,0 +1,297 @@
+const { logger } = require('@librechat/data-schemas');
+const { ToolCallTypes } = require('librechat-data-provider');
+const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
+const { validateAndUpdateTool } = require('~/server/services/ActionService');
+const { getCachedTools } = require('~/server/services/Config');
+const { updateAssistantDoc } = require('~/models/Assistant');
+const { manifestToolMap } = require('~/app/clients/tools');
+const { getOpenAIClient } = require('./helpers');
+
+/**
+ * Create an assistant.
+ * @route POST /assistants
+ * @param {AssistantCreateParams} req.body - The assistant creation parameters.
+ * @returns {Assistant} 201 - success response - application/json
+ */
+const createAssistant = async (req, res) => {
+ try {
+ /** @type {{ openai: OpenAIClient }} */
+ const { openai } = await getOpenAIClient({ req, res });
+
+ const {
+ tools = [],
+ endpoint,
+ conversation_starters,
+ append_current_datetime,
+ ...assistantData
+ } = req.body;
+ delete assistantData.conversation_starters;
+ delete assistantData.append_current_datetime;
+
+ const toolDefinitions = await getCachedTools();
+
+ assistantData.tools = tools
+ .map((tool) => {
+ if (typeof tool !== 'string') {
+ return tool;
+ }
+
+ const toolDef = toolDefinitions[tool];
+ if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
+ return Object.entries(toolDefinitions)
+ .filter(([key]) => key.startsWith(`${tool}_`))
+
+ .map(([_, val]) => val);
+ }
+
+ return toolDef;
+ })
+ .filter((tool) => tool)
+ .flat();
+
+ let azureModelIdentifier = null;
+ if (openai.locals?.azureOptions) {
+ azureModelIdentifier = assistantData.model;
+ assistantData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
+ }
+
+ assistantData.metadata = {
+ author: req.user.id,
+ endpoint,
+ };
+
+ const assistant = await openai.beta.assistants.create(assistantData);
+
+ const createData = { user: req.user.id };
+ if (conversation_starters) {
+ createData.conversation_starters = conversation_starters;
+ }
+ if (append_current_datetime !== undefined) {
+ createData.append_current_datetime = append_current_datetime;
+ }
+
+ const document = await updateAssistantDoc({ assistant_id: assistant.id }, createData);
+
+ if (azureModelIdentifier) {
+ assistant.model = azureModelIdentifier;
+ }
+
+ if (document.conversation_starters) {
+ assistant.conversation_starters = document.conversation_starters;
+ }
+ if (append_current_datetime !== undefined) {
+ assistant.append_current_datetime = append_current_datetime;
+ }
+
+ logger.debug('/assistants/', assistant);
+ res.status(201).json(assistant);
+ } catch (error) {
+ logger.error('[/assistants] Error creating assistant', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Modifies an assistant.
+ * @param {object} params
+ * @param {ServerRequest} params.req
+ * @param {OpenAIClient} params.openai
+ * @param {string} params.assistant_id
+ * @param {AssistantUpdateParams} params.updateData
+ * @returns {Promise} The updated assistant.
+ */
+const updateAssistant = async ({ req, openai, assistant_id, updateData }) => {
+ await validateAuthor({ req, openai });
+ const tools = [];
+ let conversation_starters = null;
+
+ if (updateData?.conversation_starters) {
+ const conversationStartersUpdate = await updateAssistantDoc(
+ { assistant_id: assistant_id },
+ { conversation_starters: updateData.conversation_starters },
+ );
+ conversation_starters = conversationStartersUpdate.conversation_starters;
+
+ delete updateData.conversation_starters;
+ }
+
+ if (updateData?.append_current_datetime !== undefined) {
+ await updateAssistantDoc(
+ { assistant_id: assistant_id },
+ { append_current_datetime: updateData.append_current_datetime },
+ );
+ delete updateData.append_current_datetime;
+ }
+
+ let hasFileSearch = false;
+ for (const tool of updateData.tools ?? []) {
+ const toolDefinitions = await getCachedTools();
+ let actualTool = typeof tool === 'string' ? toolDefinitions[tool] : tool;
+
+ if (!actualTool && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
+ actualTool = Object.entries(toolDefinitions)
+ .filter(([key]) => key.startsWith(`${tool}_`))
+
+ .map(([_, val]) => val);
+ } else if (!actualTool) {
+ continue;
+ }
+
+ if (Array.isArray(actualTool)) {
+ for (const subTool of actualTool) {
+ if (!subTool.function) {
+ tools.push(subTool);
+ continue;
+ }
+
+ const updatedTool = await validateAndUpdateTool({ req, tool: subTool, assistant_id });
+ if (updatedTool) {
+ tools.push(updatedTool);
+ }
+ }
+ continue;
+ }
+
+ if (actualTool.type === ToolCallTypes.FILE_SEARCH) {
+ hasFileSearch = true;
+ }
+
+ if (!actualTool.function) {
+ tools.push(actualTool);
+ continue;
+ }
+
+ const updatedTool = await validateAndUpdateTool({ req, tool: actualTool, assistant_id });
+ if (updatedTool) {
+ tools.push(updatedTool);
+ }
+ }
+
+ if (hasFileSearch && !updateData.tool_resources) {
+ const assistant = await openai.beta.assistants.retrieve(assistant_id);
+ updateData.tool_resources = assistant.tool_resources ?? null;
+ }
+
+ if (hasFileSearch && !updateData.tool_resources?.file_search) {
+ updateData.tool_resources = {
+ ...(updateData.tool_resources ?? {}),
+ file_search: {
+ vector_store_ids: [],
+ },
+ };
+ }
+
+ updateData.tools = tools;
+
+ if (openai.locals?.azureOptions && updateData.model) {
+ updateData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
+ }
+
+ const assistant = await openai.beta.assistants.update(assistant_id, updateData);
+
+ if (conversation_starters) {
+ assistant.conversation_starters = conversation_starters;
+ }
+
+ return assistant;
+};
+
+/**
+ * Modifies an assistant with the resource file id.
+ * @param {object} params
+ * @param {ServerRequest} params.req
+ * @param {OpenAIClient} params.openai
+ * @param {string} params.assistant_id
+ * @param {string} params.tool_resource
+ * @param {string} params.file_id
+ * @returns {Promise} The updated assistant.
+ */
+const addResourceFileId = async ({ req, openai, assistant_id, tool_resource, file_id }) => {
+ const assistant = await openai.beta.assistants.retrieve(assistant_id);
+ const { tool_resources = {} } = assistant;
+ if (tool_resources[tool_resource]) {
+ tool_resources[tool_resource].file_ids.push(file_id);
+ } else {
+ tool_resources[tool_resource] = { file_ids: [file_id] };
+ }
+
+ delete assistant.id;
+ return await updateAssistant({
+ req,
+ openai,
+ assistant_id,
+ updateData: { tools: assistant.tools, tool_resources },
+ });
+};
+
+/**
+ * Deletes a file ID from an assistant's resource.
+ * @param {object} params
+ * @param {ServerRequest} params.req
+ * @param {OpenAIClient} params.openai
+ * @param {string} params.assistant_id
+ * @param {string} [params.tool_resource]
+ * @param {string} params.file_id
+ * @param {AssistantUpdateParams} params.updateData
+ * @returns {Promise} The updated assistant.
+ */
+const deleteResourceFileId = async ({ req, openai, assistant_id, tool_resource, file_id }) => {
+ const assistant = await openai.beta.assistants.retrieve(assistant_id);
+ const { tool_resources = {} } = assistant;
+
+ if (tool_resource && tool_resources[tool_resource]) {
+ const resource = tool_resources[tool_resource];
+ const index = resource.file_ids.indexOf(file_id);
+ if (index !== -1) {
+ resource.file_ids.splice(index, 1);
+ }
+ } else {
+ for (const resourceKey in tool_resources) {
+ const resource = tool_resources[resourceKey];
+ const index = resource.file_ids.indexOf(file_id);
+ if (index !== -1) {
+ resource.file_ids.splice(index, 1);
+ break;
+ }
+ }
+ }
+
+ delete assistant.id;
+ return await updateAssistant({
+ req,
+ openai,
+ assistant_id,
+ updateData: { tools: assistant.tools, tool_resources },
+ });
+};
+
+/**
+ * Modifies an assistant.
+ * @route PATCH /assistants/:id
+ * @param {object} req - Express Request
+ * @param {object} req.params - Request params
+ * @param {string} req.params.id - Assistant identifier.
+ * @param {AssistantUpdateParams} req.body - The assistant update parameters.
+ * @returns {Assistant} 200 - success response - application/json
+ */
+const patchAssistant = async (req, res) => {
+ try {
+ const { openai } = await getOpenAIClient({ req, res });
+ const assistant_id = req.params.id;
+ const { endpoint: _e, ...updateData } = req.body;
+ updateData.tools = updateData.tools ?? [];
+ const updatedAssistant = await updateAssistant({ req, openai, assistant_id, updateData });
+ res.json(updatedAssistant);
+ } catch (error) {
+ logger.error('[/assistants/:id] Error updating assistant', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+module.exports = {
+ patchAssistant,
+ createAssistant,
+ updateAssistant,
+ addResourceFileId,
+ deleteResourceFileId,
+};
diff --git a/api/server/controllers/auth/LoginController.js b/api/server/controllers/auth/LoginController.js
new file mode 100644
index 0000000000..930eee86be
--- /dev/null
+++ b/api/server/controllers/auth/LoginController.js
@@ -0,0 +1,30 @@
+const { logger } = require('@librechat/data-schemas');
+const { generate2FATempToken } = require('~/server/services/twoFactorService');
+const { setAuthTokens } = require('~/server/services/AuthService');
+
+const loginController = async (req, res) => {
+ try {
+ if (!req.user) {
+ return res.status(400).json({ message: 'Invalid credentials' });
+ }
+
+ if (req.user.twoFactorEnabled) {
+ const tempToken = generate2FATempToken(req.user._id);
+ return res.status(200).json({ twoFAPending: true, tempToken });
+ }
+
+ const { password: _p, totpSecret: _t, __v, ...user } = req.user;
+ user.id = user._id.toString();
+
+ const token = await setAuthTokens(req.user._id, res);
+
+ return res.status(200).send({ token, user });
+ } catch (err) {
+ logger.error('[loginController]', err);
+ return res.status(500).json({ message: 'Something went wrong' });
+ }
+};
+
+module.exports = {
+ loginController,
+};
diff --git a/api/server/controllers/auth/LogoutController.js b/api/server/controllers/auth/LogoutController.js
new file mode 100644
index 0000000000..02d3d0302d
--- /dev/null
+++ b/api/server/controllers/auth/LogoutController.js
@@ -0,0 +1,47 @@
+const cookies = require('cookie');
+const { isEnabled } = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+const { logoutUser } = require('~/server/services/AuthService');
+const { getOpenIdConfig } = require('~/strategies');
+
+const logoutController = async (req, res) => {
+ const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null;
+ try {
+ const logout = await logoutUser(req, refreshToken);
+ const { status, message } = logout;
+ res.clearCookie('refreshToken');
+ res.clearCookie('token_provider');
+ const response = { message };
+ if (
+ req.user.openidId != null &&
+ isEnabled(process.env.OPENID_USE_END_SESSION_ENDPOINT) &&
+ process.env.OPENID_ISSUER
+ ) {
+ const openIdConfig = getOpenIdConfig();
+ if (!openIdConfig) {
+ logger.warn(
+ '[logoutController] OpenID config not found. Please verify that the open id configuration and initialization are correct.',
+ );
+ } else {
+ const endSessionEndpoint = openIdConfig
+ ? openIdConfig.serverMetadata().end_session_endpoint
+ : null;
+ if (endSessionEndpoint) {
+ response.redirect = endSessionEndpoint;
+ } else {
+ logger.warn(
+ '[logoutController] end_session_endpoint not found in OpenID issuer metadata. Please verify that the issuer is correct.',
+ );
+ }
+ }
+ }
+ return res.status(status).send(response);
+ } catch (err) {
+ logger.error('[logoutController]', err);
+ return res.status(500).json({ message: err.message });
+ }
+};
+
+module.exports = {
+ logoutController,
+};
diff --git a/api/server/controllers/auth/TwoFactorAuthController.js b/api/server/controllers/auth/TwoFactorAuthController.js
new file mode 100644
index 0000000000..9e4af2e986
--- /dev/null
+++ b/api/server/controllers/auth/TwoFactorAuthController.js
@@ -0,0 +1,61 @@
+const jwt = require('jsonwebtoken');
+const { logger } = require('@librechat/data-schemas');
+const {
+ verifyTOTP,
+ getTOTPSecret,
+ verifyBackupCode,
+} = require('~/server/services/twoFactorService');
+const { setAuthTokens } = require('~/server/services/AuthService');
+const { getUserById } = require('~/models');
+
+/**
+ * Verifies the 2FA code during login using a temporary token.
+ */
+const verify2FAWithTempToken = async (req, res) => {
+ try {
+ const { tempToken, token, backupCode } = req.body;
+ if (!tempToken) {
+ return res.status(400).json({ message: 'Missing temporary token' });
+ }
+
+ let payload;
+ try {
+ payload = jwt.verify(tempToken, process.env.JWT_SECRET);
+ } catch (err) {
+ logger.error('Failed to verify temporary token:', err);
+ return res.status(401).json({ message: 'Invalid or expired temporary token' });
+ }
+
+ const user = await getUserById(payload.userId, '+totpSecret +backupCodes');
+ if (!user || !user.twoFactorEnabled) {
+ return res.status(400).json({ message: '2FA is not enabled for this user' });
+ }
+
+ const secret = await getTOTPSecret(user.totpSecret);
+ let isVerified = false;
+ if (token) {
+ isVerified = await verifyTOTP(secret, token);
+ } else if (backupCode) {
+ isVerified = await verifyBackupCode({ user, backupCode });
+ }
+
+ if (!isVerified) {
+ return res.status(401).json({ message: 'Invalid 2FA code or backup code' });
+ }
+
+ const userData = user.toObject ? user.toObject() : { ...user };
+ delete userData.__v;
+ delete userData.password;
+ delete userData.totpSecret;
+ delete userData.backupCodes;
+ userData.id = user._id.toString();
+
+ const authToken = await setAuthTokens(user._id, res);
+ return res.status(200).json({ token: authToken, user: userData });
+ } catch (err) {
+ logger.error('[verify2FAWithTempToken]', err);
+ return res.status(500).json({ message: 'Something went wrong' });
+ }
+};
+
+module.exports = { verify2FAWithTempToken };
diff --git a/api/server/controllers/mcp.js b/api/server/controllers/mcp.js
new file mode 100644
index 0000000000..1afd7095a6
--- /dev/null
+++ b/api/server/controllers/mcp.js
@@ -0,0 +1,271 @@
+/**
+ * MCP Tools Controller
+ * Handles MCP-specific tool endpoints, decoupled from regular LibreChat tools
+ *
+ * @import { MCPServerRegistry } from '@librechat/api'
+ * @import { MCPServerDocument } from 'librechat-data-provider'
+ */
+const { logger } = require('@librechat/data-schemas');
+const { Constants, MCPServerUserInputSchema } = require('librechat-data-provider');
+const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config');
+const { getMCPManager, getMCPServersRegistry } = require('~/config');
+
+/**
+ * Get all MCP tools available to the user
+ */
+const getMCPTools = async (req, res) => {
+ try {
+ const userId = req.user?.id;
+ if (!userId) {
+ logger.warn('[getMCPTools] User ID not found in request');
+ return res.status(401).json({ message: 'Unauthorized' });
+ }
+
+ const mcpConfig = await getMCPServersRegistry().getAllServerConfigs(userId);
+ const configuredServers = mcpConfig ? Object.keys(mcpConfig) : [];
+
+ if (!mcpConfig || Object.keys(mcpConfig).length == 0) {
+ return res.status(200).json({ servers: {} });
+ }
+
+ const mcpManager = getMCPManager();
+ const mcpServers = {};
+
+ const cachePromises = configuredServers.map((serverName) =>
+ getMCPServerTools(userId, serverName).then((tools) => ({ serverName, tools })),
+ );
+ const cacheResults = await Promise.all(cachePromises);
+
+ const serverToolsMap = new Map();
+ for (const { serverName, tools } of cacheResults) {
+ if (tools) {
+ serverToolsMap.set(serverName, tools);
+ continue;
+ }
+
+ let serverTools;
+ try {
+ serverTools = await mcpManager.getServerToolFunctions(userId, serverName);
+ } catch (error) {
+ logger.error(`[getMCPTools] Error fetching tools for server ${serverName}:`, error);
+ continue;
+ }
+ if (!serverTools) {
+ logger.debug(`[getMCPTools] No tools found for server ${serverName}`);
+ continue;
+ }
+ serverToolsMap.set(serverName, serverTools);
+
+ if (Object.keys(serverTools).length > 0) {
+ // Cache asynchronously without blocking
+ cacheMCPServerTools({ userId, serverName, serverTools }).catch((err) =>
+ logger.error(`[getMCPTools] Failed to cache tools for ${serverName}:`, err),
+ );
+ }
+ }
+
+ // Process each configured server
+ for (const serverName of configuredServers) {
+ try {
+ const serverTools = serverToolsMap.get(serverName);
+
+ // Get server config once
+ const serverConfig = mcpConfig[serverName];
+ const rawServerConfig = await getMCPServersRegistry().getServerConfig(serverName, userId);
+
+ // Initialize server object with all server-level data
+ const server = {
+ name: serverName,
+ icon: rawServerConfig?.iconPath || '',
+ authenticated: true,
+ authConfig: [],
+ tools: [],
+ };
+
+ // Set authentication config once for the server
+ if (serverConfig?.customUserVars) {
+ const customVarKeys = Object.keys(serverConfig.customUserVars);
+ if (customVarKeys.length > 0) {
+ server.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
+ authField: key,
+ label: value.title || key,
+ description: value.description || '',
+ }));
+ server.authenticated = false;
+ }
+ }
+
+ // Process tools efficiently - no need for convertMCPToolToPlugin
+ if (serverTools) {
+ for (const [toolKey, toolData] of Object.entries(serverTools)) {
+ if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
+ continue;
+ }
+
+ const toolName = toolKey.split(Constants.mcp_delimiter)[0];
+ server.tools.push({
+ name: toolName,
+ pluginKey: toolKey,
+ description: toolData.function.description || '',
+ });
+ }
+ }
+
+ // Only add server if it has tools or is configured
+ if (server.tools.length > 0 || serverConfig) {
+ mcpServers[serverName] = server;
+ }
+ } catch (error) {
+ logger.error(`[getMCPTools] Error loading tools for server ${serverName}:`, error);
+ }
+ }
+
+ res.status(200).json({ servers: mcpServers });
+ } catch (error) {
+ logger.error('[getMCPTools]', error);
+ res.status(500).json({ message: error.message });
+ }
+};
+/**
+ * Get all MCP servers with permissions
+ * @route GET /api/mcp/servers
+ */
+const getMCPServersList = async (req, res) => {
+ try {
+ const userId = req.user?.id;
+ if (!userId) {
+ return res.status(401).json({ message: 'Unauthorized' });
+ }
+
+ // 2. Get all server configs from registry (YAML + DB)
+ const serverConfigs = await getMCPServersRegistry().getAllServerConfigs(userId);
+
+ return res.json(serverConfigs);
+ } catch (error) {
+ logger.error('[getMCPServersList]', error);
+ res.status(500).json({ error: error.message });
+ }
+};
+
+/**
+ * Create MCP server
+ * @route POST /api/mcp/servers
+ */
+const createMCPServerController = async (req, res) => {
+ try {
+ const userId = req.user?.id;
+ const { config } = req.body;
+
+ const validation = MCPServerUserInputSchema.safeParse(config);
+ if (!validation.success) {
+ return res.status(400).json({
+ message: 'Invalid configuration',
+ errors: validation.error.errors,
+ });
+ }
+ const result = await getMCPServersRegistry().addServer(
+ 'temp_server_name',
+ validation.data,
+ 'DB',
+ userId,
+ );
+ res.status(201).json({
+ serverName: result.serverName,
+ ...result.config,
+ });
+ } catch (error) {
+ logger.error('[createMCPServer]', error);
+ if (error.message?.startsWith('MCP_INSPECTION_FAILED')) {
+ return res.status(400).json({
+ error: 'MCP_INSPECTION_FAILED',
+ message: error.message,
+ });
+ }
+ res.status(500).json({ message: error.message });
+ }
+};
+
+/**
+ * Get MCP server by ID
+ */
+const getMCPServerById = async (req, res) => {
+ try {
+ const userId = req.user?.id;
+ const { serverName } = req.params;
+ if (!serverName) {
+ return res.status(400).json({ message: 'Server name is required' });
+ }
+ const parsedConfig = await getMCPServersRegistry().getServerConfig(serverName, userId);
+
+ if (!parsedConfig) {
+ return res.status(404).json({ message: 'MCP server not found' });
+ }
+
+ res.status(200).json(parsedConfig);
+ } catch (error) {
+ logger.error('[getMCPServerById]', error);
+ res.status(500).json({ message: error.message });
+ }
+};
+
+/**
+ * Update MCP server
+ * @route PATCH /api/mcp/servers/:serverName
+ */
+const updateMCPServerController = async (req, res) => {
+ try {
+ const userId = req.user?.id;
+ const { serverName } = req.params;
+ const { config } = req.body;
+
+ const validation = MCPServerUserInputSchema.safeParse(config);
+ if (!validation.success) {
+ return res.status(400).json({
+ message: 'Invalid configuration',
+ errors: validation.error.errors,
+ });
+ }
+ const parsedConfig = await getMCPServersRegistry().updateServer(
+ serverName,
+ validation.data,
+ 'DB',
+ userId,
+ );
+
+ res.status(200).json(parsedConfig);
+ } catch (error) {
+ logger.error('[updateMCPServer]', error);
+ if (error.message?.startsWith('MCP_INSPECTION_FAILED:')) {
+ return res.status(400).json({
+ error: 'MCP_INSPECTION_FAILED',
+ message: error.message,
+ });
+ }
+ res.status(500).json({ message: error.message });
+ }
+};
+
+/**
+ * Delete MCP server
+ * @route DELETE /api/mcp/servers/:serverName
+ */
+const deleteMCPServerController = async (req, res) => {
+ try {
+ const userId = req.user?.id;
+ const { serverName } = req.params;
+ await getMCPServersRegistry().removeServer(serverName, 'DB', userId);
+ res.status(200).json({ message: 'MCP server deleted successfully' });
+ } catch (error) {
+ logger.error('[deleteMCPServer]', error);
+ res.status(500).json({ message: error.message });
+ }
+};
+
+module.exports = {
+ getMCPTools,
+ getMCPServersList,
+ createMCPServerController,
+ getMCPServerById,
+ updateMCPServerController,
+ deleteMCPServerController,
+};
diff --git a/api/server/controllers/tools.js b/api/server/controllers/tools.js
new file mode 100644
index 0000000000..14a757e2bc
--- /dev/null
+++ b/api/server/controllers/tools.js
@@ -0,0 +1,252 @@
+const { nanoid } = require('nanoid');
+const { EnvVar } = require('@librechat/agents');
+const { logger } = require('@librechat/data-schemas');
+const { checkAccess, loadWebSearchAuth } = require('@librechat/api');
+const {
+ Tools,
+ AuthType,
+ Permissions,
+ ToolCallTypes,
+ PermissionTypes,
+} = require('librechat-data-provider');
+const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process');
+const { processCodeOutput } = require('~/server/services/Files/Code/process');
+const { createToolCall, getToolCallsByConvo } = require('~/models/ToolCall');
+const { loadAuthValues } = require('~/server/services/Tools/credentials');
+const { loadTools } = require('~/app/clients/tools/util');
+const { getRoleByName } = require('~/models/Role');
+const { getMessage } = require('~/models/Message');
+
+const fieldsMap = {
+ [Tools.execute_code]: [EnvVar.CODE_API_KEY],
+};
+
+const toolAccessPermType = {
+ [Tools.execute_code]: PermissionTypes.RUN_CODE,
+};
+
+/**
+ * Verifies web search authentication, ensuring each category has at least
+ * one fully authenticated service.
+ *
+ * @param {ServerRequest} req - The request object
+ * @param {ServerResponse} res - The response object
+ * @returns {Promise} A promise that resolves when the function has completed
+ */
+const verifyWebSearchAuth = async (req, res) => {
+ try {
+ const appConfig = req.config;
+ const userId = req.user.id;
+ /** @type {TCustomConfig['webSearch']} */
+ const webSearchConfig = appConfig?.webSearch || {};
+ const result = await loadWebSearchAuth({
+ userId,
+ loadAuthValues,
+ webSearchConfig,
+ throwError: false,
+ });
+
+ return res.status(200).json({
+ authenticated: result.authenticated,
+ authTypes: result.authTypes,
+ });
+ } catch (error) {
+ console.error('Error in verifyWebSearchAuth:', error);
+ return res.status(500).json({ message: error.message });
+ }
+};
+
+/**
+ * @param {ServerRequest} req - The request object, containing information about the HTTP request.
+ * @param {ServerResponse} res - The response object, used to send back the desired HTTP response.
+ * @returns {Promise} A promise that resolves when the function has completed.
+ */
+const verifyToolAuth = async (req, res) => {
+ try {
+ const { toolId } = req.params;
+ if (toolId === Tools.web_search) {
+ return await verifyWebSearchAuth(req, res);
+ }
+ const authFields = fieldsMap[toolId];
+ if (!authFields) {
+ res.status(404).json({ message: 'Tool not found' });
+ return;
+ }
+ let result;
+ try {
+ result = await loadAuthValues({
+ userId: req.user.id,
+ authFields,
+ throwError: false,
+ });
+ } catch (error) {
+ logger.error('Error loading auth values', error);
+ res.status(200).json({ authenticated: false, message: AuthType.USER_PROVIDED });
+ return;
+ }
+ let isUserProvided = false;
+ for (const field of authFields) {
+ if (!result[field]) {
+ res.status(200).json({ authenticated: false, message: AuthType.USER_PROVIDED });
+ return;
+ }
+ if (!isUserProvided && process.env[field] !== result[field]) {
+ isUserProvided = true;
+ }
+ }
+ res.status(200).json({
+ authenticated: true,
+ message: isUserProvided ? AuthType.USER_PROVIDED : AuthType.SYSTEM_DEFINED,
+ });
+ } catch (error) {
+ res.status(500).json({ message: error.message });
+ }
+};
+
+/**
+ * @param {ServerRequest} req - The request object, containing information about the HTTP request.
+ * @param {ServerResponse} res - The response object, used to send back the desired HTTP response.
+ * @param {NextFunction} next - The next middleware function to call.
+ * @returns {Promise} A promise that resolves when the function has completed.
+ */
+const callTool = async (req, res) => {
+ try {
+ const appConfig = req.config;
+ const { toolId = '' } = req.params;
+ if (!fieldsMap[toolId]) {
+ logger.warn(`[${toolId}/call] User ${req.user.id} attempted call to invalid tool`);
+ res.status(404).json({ message: 'Tool not found' });
+ return;
+ }
+
+ const { partIndex, blockIndex, messageId, conversationId, ...args } = req.body;
+ if (!messageId) {
+ logger.warn(`[${toolId}/call] User ${req.user.id} attempted call without message ID`);
+ res.status(400).json({ message: 'Message ID required' });
+ return;
+ }
+
+ const message = await getMessage({ user: req.user.id, messageId });
+ if (!message) {
+ logger.debug(`[${toolId}/call] User ${req.user.id} attempted call with invalid message ID`);
+ res.status(404).json({ message: 'Message not found' });
+ return;
+ }
+ logger.debug(`[${toolId}/call] User: ${req.user.id}`);
+ let hasAccess = true;
+ if (toolAccessPermType[toolId]) {
+ hasAccess = await checkAccess({
+ user: req.user,
+ permissionType: toolAccessPermType[toolId],
+ permissions: [Permissions.USE],
+ getRoleByName,
+ });
+ }
+ if (!hasAccess) {
+ logger.warn(
+ `[${toolAccessPermType[toolId]}] Forbidden: Insufficient permissions for User ${req.user.id}: ${Permissions.USE}`,
+ );
+ return res.status(403).json({ message: 'Forbidden: Insufficient permissions' });
+ }
+ const { loadedTools } = await loadTools({
+ user: req.user.id,
+ tools: [toolId],
+ functions: true,
+ options: {
+ req,
+ returnMetadata: true,
+ processFileURL,
+ uploadImageBuffer,
+ },
+ webSearch: appConfig.webSearch,
+ fileStrategy: appConfig.fileStrategy,
+ imageOutputType: appConfig.imageOutputType,
+ });
+
+ const tool = loadedTools[0];
+ const toolCallId = `${req.user.id}_${nanoid()}`;
+ const result = await tool.invoke({
+ args,
+ name: toolId,
+ id: toolCallId,
+ type: ToolCallTypes.TOOL_CALL,
+ });
+
+ const { content, artifact } = result;
+ const toolCallData = {
+ toolId,
+ messageId,
+ partIndex,
+ blockIndex,
+ conversationId,
+ result: content,
+ user: req.user.id,
+ };
+
+ if (!artifact || !artifact.files || toolId !== Tools.execute_code) {
+ createToolCall(toolCallData).catch((error) => {
+ logger.error(`Error creating tool call: ${error.message}`);
+ });
+ return res.status(200).json({
+ result: content,
+ });
+ }
+
+ const artifactPromises = [];
+ for (const file of artifact.files) {
+ const { id, name } = file;
+ artifactPromises.push(
+ (async () => {
+ const fileMetadata = await processCodeOutput({
+ req,
+ id,
+ name,
+ apiKey: tool.apiKey,
+ messageId,
+ toolCallId,
+ conversationId,
+ session_id: artifact.session_id,
+ });
+
+ if (!fileMetadata) {
+ return null;
+ }
+
+ return fileMetadata;
+ })().catch((error) => {
+ logger.error('Error processing code output:', error);
+ return null;
+ }),
+ );
+ }
+ const attachments = await Promise.all(artifactPromises);
+ toolCallData.attachments = attachments;
+ createToolCall(toolCallData).catch((error) => {
+ logger.error(`Error creating tool call: ${error.message}`);
+ });
+ res.status(200).json({
+ result: content,
+ attachments,
+ });
+ } catch (error) {
+ logger.error('Error calling tool', error);
+ res.status(500).json({ message: 'Error calling tool' });
+ }
+};
+
+const getToolCalls = async (req, res) => {
+ try {
+ const { conversationId } = req.query;
+ const toolCalls = await getToolCallsByConvo(conversationId, req.user.id);
+ res.status(200).json(toolCalls);
+ } catch (error) {
+ logger.error('Error getting tool calls', error);
+ res.status(500).json({ message: 'Error getting tool calls' });
+ }
+};
+
+module.exports = {
+ callTool,
+ getToolCalls,
+ verifyToolAuth,
+};
diff --git a/api/server/experimental.js b/api/server/experimental.js
new file mode 100644
index 0000000000..91ef9ef286
--- /dev/null
+++ b/api/server/experimental.js
@@ -0,0 +1,436 @@
+require('dotenv').config();
+const fs = require('fs');
+const path = require('path');
+require('module-alias')({ base: path.resolve(__dirname, '..') });
+const cluster = require('cluster');
+const Redis = require('ioredis');
+const cors = require('cors');
+const axios = require('axios');
+const express = require('express');
+const passport = require('passport');
+const compression = require('compression');
+const cookieParser = require('cookie-parser');
+const { logger } = require('@librechat/data-schemas');
+const mongoSanitize = require('express-mongo-sanitize');
+const {
+ isEnabled,
+ ErrorController,
+ performStartupChecks,
+ handleJsonParseError,
+ initializeFileStorage,
+} = require('@librechat/api');
+const { connectDb, indexSync } = require('~/db');
+const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
+const createValidateImageRequest = require('./middleware/validateImageRequest');
+const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
+const { updateInterfacePermissions } = require('~/models/interface');
+const { checkMigrations } = require('./services/start/migration');
+const initializeMCPs = require('./services/initializeMCPs');
+const configureSocialLogins = require('./socialLogins');
+const { getAppConfig } = require('./services/Config');
+const staticCache = require('./utils/staticCache');
+const noIndex = require('./middleware/noIndex');
+const { seedDatabase } = require('~/models');
+const routes = require('./routes');
+
+const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
+
+/** Allow PORT=0 to be used for automatic free port assignment */
+const port = isNaN(Number(PORT)) ? 3080 : Number(PORT);
+const host = HOST || 'localhost';
+const trusted_proxy = Number(TRUST_PROXY) || 1;
+
+/** Number of worker processes to spawn (simulating multiple pods) */
+const workers = Number(process.env.CLUSTER_WORKERS) || 4;
+
+/** Helper to wrap log messages for better visibility */
+const wrapLogMessage = (msg) => {
+ return `\n${'='.repeat(50)}\n${msg}\n${'='.repeat(50)}`;
+};
+
+/**
+ * Flushes the Redis cache on startup
+ * This ensures a clean state for testing multi-pod MCP connection issues
+ */
+const flushRedisCache = async () => {
+ /** Skip cache flush if Redis is not enabled */
+ if (!isEnabled(process.env.USE_REDIS)) {
+ logger.info('Redis is not enabled, skipping cache flush');
+ return;
+ }
+
+ const redisConfig = {
+ host: process.env.REDIS_HOST || 'localhost',
+ port: process.env.REDIS_PORT || 6379,
+ };
+
+ if (process.env.REDIS_PASSWORD) {
+ redisConfig.password = process.env.REDIS_PASSWORD;
+ }
+
+ /** Handle Redis Cluster configuration */
+ if (isEnabled(process.env.USE_REDIS_CLUSTER) || process.env.REDIS_URI?.includes(',')) {
+ logger.info('Detected Redis Cluster configuration');
+ const uris = process.env.REDIS_URI?.split(',').map((uri) => {
+ const url = new URL(uri.trim());
+ return {
+ host: url.hostname,
+ port: parseInt(url.port || '6379', 10),
+ };
+ });
+ const redis = new Redis.Cluster(uris, {
+ redisOptions: {
+ password: process.env.REDIS_PASSWORD,
+ },
+ });
+
+ try {
+ logger.info('Attempting to connect to Redis Cluster...');
+ await redis.ping();
+ logger.info('Connected to Redis Cluster. Executing flushall...');
+ const result = await Promise.race([
+ redis.flushall(),
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Flush timeout')), 10000)),
+ ]);
+ logger.info('Redis Cluster cache flushed successfully', { result });
+ } catch (err) {
+ logger.error('Error while flushing Redis Cluster cache:', err);
+ throw err;
+ } finally {
+ redis.disconnect();
+ }
+ return;
+ }
+
+ /** Handle single Redis instance */
+ const redis = new Redis(redisConfig);
+
+ try {
+ logger.info('Attempting to connect to Redis...');
+ await redis.ping();
+ logger.info('Connected to Redis. Executing flushall...');
+ const result = await Promise.race([
+ redis.flushall(),
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Flush timeout')), 5000)),
+ ]);
+ logger.info('Redis cache flushed successfully', { result });
+ } catch (err) {
+ logger.error('Error while flushing Redis cache:', err);
+ throw err;
+ } finally {
+ redis.disconnect();
+ }
+};
+
+/**
+ * Master process
+ * Manages worker processes and handles graceful shutdowns
+ */
+if (cluster.isMaster) {
+ logger.info(wrapLogMessage(`Master ${process.pid} is starting...`));
+ logger.info(`Spawning ${workers} workers to simulate multi-pod environment`);
+
+ let activeWorkers = 0;
+ const startTime = Date.now();
+
+ /** Flush Redis cache before starting workers */
+ flushRedisCache()
+ .then(() => {
+ logger.info('Cache flushed, forking workers...');
+ for (let i = 0; i < workers; i++) {
+ cluster.fork();
+ }
+ })
+ .catch((err) => {
+ logger.error('Unable to flush Redis cache, not forking workers:', err);
+ process.exit(1);
+ });
+
+ /** Track worker lifecycle */
+ cluster.on('online', (worker) => {
+ activeWorkers++;
+ const uptime = ((Date.now() - startTime) / 1000).toFixed(2);
+ logger.info(
+ `Worker ${worker.process.pid} is online (${activeWorkers}/${workers}) after ${uptime}s`,
+ );
+
+ /** Notify the last worker to perform one-time initialization tasks */
+ if (activeWorkers === workers) {
+ const allWorkers = Object.values(cluster.workers);
+ const lastWorker = allWorkers[allWorkers.length - 1];
+ if (lastWorker) {
+ logger.info(wrapLogMessage(`All ${workers} workers are online`));
+ lastWorker.send({ type: 'last-worker' });
+ }
+ }
+ });
+
+ cluster.on('exit', (worker, code, signal) => {
+ activeWorkers--;
+ logger.error(
+ `Worker ${worker.process.pid} died (${activeWorkers}/${workers}). Code: ${code}, Signal: ${signal}`,
+ );
+ logger.info('Starting a new worker to replace it...');
+ cluster.fork();
+ });
+
+ /** Graceful shutdown on SIGTERM/SIGINT */
+ const shutdown = () => {
+ logger.info('Master received shutdown signal, terminating workers...');
+ for (const id in cluster.workers) {
+ cluster.workers[id].kill();
+ }
+ setTimeout(() => {
+ logger.info('Forcing shutdown after timeout');
+ process.exit(0);
+ }, 10000);
+ };
+
+ process.on('SIGTERM', shutdown);
+ process.on('SIGINT', shutdown);
+} else {
+ /**
+ * Worker process
+ * Each worker runs a full Express server instance
+ */
+ const app = express();
+
+ const startServer = async () => {
+ logger.info(`Worker ${process.pid} initializing...`);
+
+ if (typeof Bun !== 'undefined') {
+ axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
+ }
+
+ /** Connect to MongoDB */
+ await connectDb();
+ logger.info(`Worker ${process.pid}: Connected to MongoDB`);
+
+ /** Background index sync (non-blocking) */
+ indexSync().catch((err) => {
+ logger.error(`[Worker ${process.pid}][indexSync] Background sync failed:`, err);
+ });
+
+ app.disable('x-powered-by');
+ app.set('trust proxy', trusted_proxy);
+
+ /** Seed database (idempotent) */
+ await seedDatabase();
+
+ /** Initialize app configuration */
+ const appConfig = await getAppConfig();
+ initializeFileStorage(appConfig);
+ await performStartupChecks(appConfig);
+ await updateInterfacePermissions(appConfig);
+
+ /** Load index.html for SPA serving */
+ const indexPath = path.join(appConfig.paths.dist, 'index.html');
+ let indexHTML = fs.readFileSync(indexPath, 'utf8');
+
+ /** Support serving in subdirectory if DOMAIN_CLIENT is set */
+ if (process.env.DOMAIN_CLIENT) {
+ const clientUrl = new URL(process.env.DOMAIN_CLIENT);
+ const baseHref = clientUrl.pathname.endsWith('/')
+ ? clientUrl.pathname
+ : `${clientUrl.pathname}/`;
+ if (baseHref !== '/') {
+ logger.info(`Setting base href to ${baseHref}`);
+ indexHTML = indexHTML.replace(/base href="\/"/, `base href="${baseHref}"`);
+ }
+ }
+
+ /** Health check endpoint */
+ app.get('/health', (_req, res) => res.status(200).send('OK'));
+
+ /** Middleware */
+ app.use(noIndex);
+ app.use(express.json({ limit: '3mb' }));
+ app.use(express.urlencoded({ extended: true, limit: '3mb' }));
+
+ app.use(handleJsonParseError);
+
+ /**
+ * Express 5 Compatibility: Make req.query writable for mongoSanitize
+ * In Express 5, req.query is read-only by default, but express-mongo-sanitize needs to modify it
+ */
+ app.use((req, _res, next) => {
+ Object.defineProperty(req, 'query', {
+ ...Object.getOwnPropertyDescriptor(req, 'query'),
+ value: req.query,
+ writable: true,
+ });
+ next();
+ });
+
+ app.use(mongoSanitize());
+ app.use(cors());
+ app.use(cookieParser());
+
+ if (!isEnabled(DISABLE_COMPRESSION)) {
+ app.use(compression());
+ } else {
+ logger.warn('Response compression has been disabled via DISABLE_COMPRESSION.');
+ }
+
+ app.use(staticCache(appConfig.paths.dist));
+ app.use(staticCache(appConfig.paths.fonts));
+ app.use(staticCache(appConfig.paths.assets));
+
+ if (!ALLOW_SOCIAL_LOGIN) {
+ logger.warn('Social logins are disabled. Set ALLOW_SOCIAL_LOGIN=true to enable them.');
+ }
+
+ /** OAUTH */
+ app.use(passport.initialize());
+ passport.use(jwtLogin());
+ passport.use(passportLogin());
+
+ /** LDAP Auth */
+ if (process.env.LDAP_URL && process.env.LDAP_USER_SEARCH_BASE) {
+ passport.use(ldapLogin);
+ }
+
+ if (isEnabled(ALLOW_SOCIAL_LOGIN)) {
+ await configureSocialLogins(app);
+ }
+
+ /** Routes */
+ app.use('/oauth', routes.oauth);
+ app.use('/api/auth', routes.auth);
+ app.use('/api/actions', routes.actions);
+ app.use('/api/keys', routes.keys);
+ app.use('/api/user', routes.user);
+ app.use('/api/search', routes.search);
+ app.use('/api/messages', routes.messages);
+ app.use('/api/convos', routes.convos);
+ app.use('/api/presets', routes.presets);
+ app.use('/api/prompts', routes.prompts);
+ app.use('/api/categories', routes.categories);
+ app.use('/api/endpoints', routes.endpoints);
+ app.use('/api/balance', routes.balance);
+ app.use('/api/models', routes.models);
+ app.use('/api/plugins', routes.plugins);
+ app.use('/api/config', routes.config);
+ app.use('/api/assistants', routes.assistants);
+ app.use('/api/files', await routes.files.initialize());
+ app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute);
+ app.use('/api/share', routes.share);
+ app.use('/api/roles', routes.roles);
+ app.use('/api/agents', routes.agents);
+ app.use('/api/banner', routes.banner);
+ app.use('/api/memories', routes.memories);
+ app.use('/api/permissions', routes.accessPermissions);
+ app.use('/api/tags', routes.tags);
+ app.use('/api/mcp', routes.mcp);
+
+ /** Error handler */
+ app.use(ErrorController);
+
+ /** SPA fallback - serve index.html for all unmatched routes */
+ app.use((req, res) => {
+ res.set({
+ 'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate',
+ Pragma: process.env.INDEX_PRAGMA || 'no-cache',
+ Expires: process.env.INDEX_EXPIRES || '0',
+ });
+
+ const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US';
+ const saneLang = lang.replace(/"/g, '"');
+ let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`);
+
+ res.type('html');
+ res.send(updatedIndexHtml);
+ });
+
+ /** Start listening on shared port (cluster will distribute connections) */
+ app.listen(port, host, async (err) => {
+ if (err) {
+ logger.error(`Worker ${process.pid} failed to start server:`, err);
+ process.exit(1);
+ }
+
+ logger.info(
+ `Worker ${process.pid} started: Server listening at http://${
+ host == '0.0.0.0' ? 'localhost' : host
+ }:${port}`,
+ );
+
+ /** Initialize MCP servers and OAuth reconnection for this worker */
+ await initializeMCPs();
+ await initializeOAuthReconnectManager();
+ await checkMigrations();
+ });
+
+ /** Handle inter-process messages from master */
+ process.on('message', async (msg) => {
+ if (msg.type === 'last-worker') {
+ logger.info(
+ wrapLogMessage(
+ `Worker ${process.pid} is the last worker and can perform special initialization tasks`,
+ ),
+ );
+ /** Add any one-time initialization tasks here */
+ /** For example: scheduled jobs, cleanup tasks, etc. */
+ }
+ });
+ };
+
+ startServer().catch((err) => {
+ logger.error(`Failed to start worker ${process.pid}:`, err);
+ process.exit(1);
+ });
+
+ /** Export app for testing purposes (only available in worker processes) */
+ module.exports = app;
+}
+
+/**
+ * Uncaught exception handler
+ * Filters out known non-critical errors
+ */
+let messageCount = 0;
+process.on('uncaughtException', (err) => {
+ if (!err.message.includes('fetch failed')) {
+ logger.error('There was an uncaught error:', err);
+ }
+
+ if (err.message && err.message?.toLowerCase()?.includes('abort')) {
+ logger.warn('There was an uncatchable abort error.');
+ return;
+ }
+
+ if (err.message.includes('GoogleGenerativeAI')) {
+ logger.warn(
+ '\n\n`GoogleGenerativeAI` errors cannot be caught due to an upstream issue, see: https://github.com/google-gemini/generative-ai-js/issues/303',
+ );
+ return;
+ }
+
+ if (err.message.includes('fetch failed')) {
+ if (messageCount === 0) {
+ logger.warn('Meilisearch error, search will be disabled');
+ messageCount++;
+ }
+ return;
+ }
+
+ if (err.message.includes('OpenAIError') || err.message.includes('ChatCompletionMessage')) {
+ logger.error(
+ '\n\nAn Uncaught `OpenAIError` error may be due to your reverse-proxy setup or stream configuration, or a bug in the `openai` node package.',
+ );
+ return;
+ }
+
+ if (err.stack && err.stack.includes('@librechat/agents')) {
+ logger.error(
+ '\n\nAn error occurred in the agents system. The error has been logged and the app will continue running.',
+ {
+ message: err.message,
+ stack: err.stack,
+ },
+ );
+ return;
+ }
+
+ process.exit(1);
+});
diff --git a/api/server/index.js b/api/server/index.js
index a98b5e54fc..37ef8dc513 100644
--- a/api/server/index.js
+++ b/api/server/index.js
@@ -1,28 +1,249 @@
-const express = require('express');
-const dbConnect = require('../models/dbConnect');
+require('dotenv').config();
+const fs = require('fs');
const path = require('path');
+require('module-alias')({ base: path.resolve(__dirname, '..') });
const cors = require('cors');
+const axios = require('axios');
+const express = require('express');
+const passport = require('passport');
+const compression = require('compression');
+const cookieParser = require('cookie-parser');
+const { logger } = require('@librechat/data-schemas');
+const mongoSanitize = require('express-mongo-sanitize');
+const {
+ isEnabled,
+ ErrorController,
+ performStartupChecks,
+ handleJsonParseError,
+ initializeFileStorage,
+} = require('@librechat/api');
+const { connectDb, indexSync } = require('~/db');
+const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
+const createValidateImageRequest = require('./middleware/validateImageRequest');
+const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
+const { updateInterfacePermissions } = require('~/models/interface');
+const { checkMigrations } = require('./services/start/migration');
+const initializeMCPs = require('./services/initializeMCPs');
+const configureSocialLogins = require('./socialLogins');
+const { getAppConfig } = require('./services/Config');
+const staticCache = require('./utils/staticCache');
+const noIndex = require('./middleware/noIndex');
+const { seedDatabase } = require('~/models');
const routes = require('./routes');
+
+const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
+
+// Allow PORT=0 to be used for automatic free port assignment
+const port = isNaN(Number(PORT)) ? 3080 : Number(PORT);
+const host = HOST || 'localhost';
+const trusted_proxy = Number(TRUST_PROXY) || 1; /* trust first proxy by default */
+
const app = express();
-const port = process.env.PORT || 3080;
-const projectPath = path.join(__dirname, '..', '..', 'client');
-dbConnect().then(() => console.log('Connected to MongoDB'));
-app.use(cors());
-app.use(express.json());
-app.use(express.static(path.join(projectPath, 'public')));
+const startServer = async () => {
+ if (typeof Bun !== 'undefined') {
+ axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
+ }
+ await connectDb();
-app.get('/', function (req, res) {
- console.log(path.join(projectPath, 'public', 'index.html'));
- res.sendFile(path.join(projectPath, 'public', 'index.html'));
+ logger.info('Connected to MongoDB');
+ indexSync().catch((err) => {
+ logger.error('[indexSync] Background sync failed:', err);
+ });
+
+ app.disable('x-powered-by');
+ app.set('trust proxy', trusted_proxy);
+
+ await seedDatabase();
+ const appConfig = await getAppConfig();
+ initializeFileStorage(appConfig);
+ await performStartupChecks(appConfig);
+ await updateInterfacePermissions(appConfig);
+
+ const indexPath = path.join(appConfig.paths.dist, 'index.html');
+ let indexHTML = fs.readFileSync(indexPath, 'utf8');
+
+ // In order to provide support to serving the application in a sub-directory
+ // We need to update the base href if the DOMAIN_CLIENT is specified and not the root path
+ if (process.env.DOMAIN_CLIENT) {
+ const clientUrl = new URL(process.env.DOMAIN_CLIENT);
+ const baseHref = clientUrl.pathname.endsWith('/')
+ ? clientUrl.pathname
+ : `${clientUrl.pathname}/`;
+ if (baseHref !== '/') {
+ logger.info(`Setting base href to ${baseHref}`);
+ indexHTML = indexHTML.replace(/base href="\/"/, `base href="${baseHref}"`);
+ }
+ }
+
+ app.get('/health', (_req, res) => res.status(200).send('OK'));
+
+ /* Middleware */
+ app.use(noIndex);
+ app.use(express.json({ limit: '3mb' }));
+ app.use(express.urlencoded({ extended: true, limit: '3mb' }));
+ app.use(handleJsonParseError);
+
+ /**
+ * Express 5 Compatibility: Make req.query writable for mongoSanitize
+ * In Express 5, req.query is read-only by default, but express-mongo-sanitize needs to modify it
+ */
+ app.use((req, _res, next) => {
+ Object.defineProperty(req, 'query', {
+ ...Object.getOwnPropertyDescriptor(req, 'query'),
+ value: req.query,
+ writable: true,
+ });
+ next();
+ });
+
+ app.use(mongoSanitize());
+ app.use(cors());
+ app.use(cookieParser());
+
+ if (!isEnabled(DISABLE_COMPRESSION)) {
+ app.use(compression());
+ } else {
+ console.warn('Response compression has been disabled via DISABLE_COMPRESSION.');
+ }
+
+ app.use(staticCache(appConfig.paths.dist));
+ app.use(staticCache(appConfig.paths.fonts));
+ app.use(staticCache(appConfig.paths.assets));
+
+ if (!ALLOW_SOCIAL_LOGIN) {
+ console.warn('Social logins are disabled. Set ALLOW_SOCIAL_LOGIN=true to enable them.');
+ }
+
+ /* OAUTH */
+ app.use(passport.initialize());
+ passport.use(jwtLogin());
+ passport.use(passportLogin());
+
+ /* LDAP Auth */
+ if (process.env.LDAP_URL && process.env.LDAP_USER_SEARCH_BASE) {
+ passport.use(ldapLogin);
+ }
+
+ if (isEnabled(ALLOW_SOCIAL_LOGIN)) {
+ await configureSocialLogins(app);
+ }
+
+ app.use('/oauth', routes.oauth);
+ /* API Endpoints */
+ app.use('/api/auth', routes.auth);
+ app.use('/api/actions', routes.actions);
+ app.use('/api/keys', routes.keys);
+ app.use('/api/user', routes.user);
+ app.use('/api/search', routes.search);
+ app.use('/api/messages', routes.messages);
+ app.use('/api/convos', routes.convos);
+ app.use('/api/presets', routes.presets);
+ app.use('/api/prompts', routes.prompts);
+ app.use('/api/categories', routes.categories);
+ app.use('/api/endpoints', routes.endpoints);
+ app.use('/api/balance', routes.balance);
+ app.use('/api/models', routes.models);
+ app.use('/api/config', routes.config);
+ app.use('/api/assistants', routes.assistants);
+ app.use('/api/files', await routes.files.initialize());
+ app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute);
+ app.use('/api/share', routes.share);
+ app.use('/api/roles', routes.roles);
+ app.use('/api/agents', routes.agents);
+ app.use('/api/banner', routes.banner);
+ app.use('/api/memories', routes.memories);
+ app.use('/api/permissions', routes.accessPermissions);
+
+ app.use('/api/tags', routes.tags);
+ app.use('/api/mcp', routes.mcp);
+
+ app.use(ErrorController);
+
+ app.use((req, res) => {
+ res.set({
+ 'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate',
+ Pragma: process.env.INDEX_PRAGMA || 'no-cache',
+ Expires: process.env.INDEX_EXPIRES || '0',
+ });
+
+ const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US';
+ const saneLang = lang.replace(/"/g, '"');
+ let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`);
+
+ res.type('html');
+ res.send(updatedIndexHtml);
+ });
+
+ app.listen(port, host, async (err) => {
+ if (err) {
+ logger.error('Failed to start server:', err);
+ process.exit(1);
+ }
+
+ if (host === '0.0.0.0') {
+ logger.info(
+ `Server listening on all interfaces at port ${port}. Use http://localhost:${port} to access it`,
+ );
+ } else {
+ logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
+ }
+
+ await initializeMCPs();
+ await initializeOAuthReconnectManager();
+ await checkMigrations();
+ });
+};
+
+startServer();
+
+let messageCount = 0;
+process.on('uncaughtException', (err) => {
+ if (!err.message.includes('fetch failed')) {
+ logger.error('There was an uncaught error:', err);
+ }
+
+ if (err.message && err.message?.toLowerCase()?.includes('abort')) {
+ logger.warn('There was an uncatchable abort error.');
+ return;
+ }
+
+ if (err.message.includes('GoogleGenerativeAI')) {
+ logger.warn(
+ '\n\n`GoogleGenerativeAI` errors cannot be caught due to an upstream issue, see: https://github.com/google-gemini/generative-ai-js/issues/303',
+ );
+ return;
+ }
+
+ if (err.message.includes('fetch failed')) {
+ if (messageCount === 0) {
+ logger.warn('Meilisearch error, search will be disabled');
+ messageCount++;
+ }
+
+ return;
+ }
+
+ if (err.message.includes('OpenAIError') || err.message.includes('ChatCompletionMessage')) {
+ logger.error(
+ '\n\nAn Uncaught `OpenAIError` error may be due to your reverse-proxy setup or stream configuration, or a bug in the `openai` node package.',
+ );
+ return;
+ }
+
+ if (err.stack && err.stack.includes('@librechat/agents')) {
+ logger.error(
+ '\n\nAn error occurred in the agents system. The error has been logged and the app will continue running.',
+ {
+ message: err.message,
+ stack: err.stack,
+ },
+ );
+ return;
+ }
+
+ process.exit(1);
});
-app.use('/api/ask', routes.ask);
-app.use('/api/messages', routes.messages);
-app.use('/api/convos', routes.convos);
-app.use('/api/customGpts', routes.customGpts);
-app.use('/api/prompts', routes.prompts);
-
-app.listen(port, () => {
- console.log(`Server listening at http://localhost:${port}`);
-});
\ No newline at end of file
+/** Export app for easier testing purposes */
+module.exports = app;
diff --git a/api/server/index.spec.js b/api/server/index.spec.js
new file mode 100644
index 0000000000..4dcd34687e
--- /dev/null
+++ b/api/server/index.spec.js
@@ -0,0 +1,139 @@
+const fs = require('fs');
+const request = require('supertest');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const mongoose = require('mongoose');
+
+jest.mock('~/server/services/Config', () => ({
+ loadCustomConfig: jest.fn(() => Promise.resolve({})),
+ getAppConfig: jest.fn().mockResolvedValue({
+ paths: {
+ uploads: '/tmp',
+ dist: '/tmp/dist',
+ fonts: '/tmp/fonts',
+ assets: '/tmp/assets',
+ },
+ fileStrategy: 'local',
+ imageOutputType: 'PNG',
+ }),
+ setCachedTools: jest.fn(),
+}));
+
+jest.mock('~/app/clients/tools', () => ({
+ createOpenAIImageTools: jest.fn(() => []),
+ createYouTubeTools: jest.fn(() => []),
+ manifestToolMap: {},
+ toolkits: [],
+}));
+
+describe('Server Configuration', () => {
+ // Increase the default timeout to allow for Mongo cleanup
+ jest.setTimeout(30_000);
+
+ let mongoServer;
+ let app;
+
+ /** Mocked fs.readFileSync for index.html */
+ const originalReadFileSync = fs.readFileSync;
+ beforeAll(() => {
+ fs.readFileSync = function (filepath, options) {
+ if (filepath.includes('index.html')) {
+ return 'LibreChat
';
+ }
+ return originalReadFileSync(filepath, options);
+ };
+ });
+
+ afterAll(() => {
+ // Restore original fs.readFileSync
+ fs.readFileSync = originalReadFileSync;
+ });
+
+ beforeAll(async () => {
+ // Create the required directories and files for the test
+ const fs = require('fs');
+ const path = require('path');
+
+ const dirs = ['/tmp/dist', '/tmp/fonts', '/tmp/assets'];
+ dirs.forEach((dir) => {
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+ });
+
+ fs.writeFileSync(
+ path.join('/tmp/dist', 'index.html'),
+ 'LibreChat
',
+ );
+
+ mongoServer = await MongoMemoryServer.create();
+ process.env.MONGO_URI = mongoServer.getUri();
+ process.env.PORT = '0'; // Use a random available port
+ app = require('~/server');
+
+ // Wait for the app to be healthy
+ await healthCheckPoll(app);
+ });
+
+ afterAll(async () => {
+ await mongoServer.stop();
+ await mongoose.disconnect();
+ });
+
+ it('should return OK for /health', async () => {
+ const response = await request(app).get('/health');
+ expect(response.status).toBe(200);
+ expect(response.text).toBe('OK');
+ });
+
+ it('should not cache index page', async () => {
+ const response = await request(app).get('/');
+ expect(response.status).toBe(200);
+ expect(response.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
+ expect(response.headers['pragma']).toBe('no-cache');
+ expect(response.headers['expires']).toBe('0');
+ });
+
+ it('should return 500 for unknown errors via ErrorController', async () => {
+ // Testing the error handling here on top of unit tests to ensure the middleware is correctly integrated
+
+ // Mock MongoDB operations to fail
+ const originalFindOne = mongoose.models.User.findOne;
+ const mockError = new Error('MongoDB operation failed');
+ mongoose.models.User.findOne = jest.fn().mockImplementation(() => {
+ throw mockError;
+ });
+
+ try {
+ const response = await request(app).post('/api/auth/login').send({
+ email: 'test@example.com',
+ password: 'password123',
+ });
+
+ expect(response.status).toBe(500);
+ expect(response.text).toBe('An unknown error occurred.');
+ } finally {
+ // Restore original function
+ mongoose.models.User.findOne = originalFindOne;
+ }
+ });
+});
+
+// Polls the /health endpoint every 30ms for up to 10 seconds to wait for the server to start completely
+async function healthCheckPoll(app, retries = 0) {
+ const maxRetries = Math.floor(10000 / 30); // 10 seconds / 30ms
+ try {
+ const response = await request(app).get('/health');
+ if (response.status === 200) {
+ return; // App is healthy
+ }
+ } catch {
+ // Ignore connection errors during polling
+ }
+
+ if (retries < maxRetries) {
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ await healthCheckPoll(app, retries + 1);
+ } else {
+ throw new Error('App did not become healthy within 10 seconds.');
+ }
+}
diff --git a/api/server/middleware/abortControllers.js b/api/server/middleware/abortControllers.js
new file mode 100644
index 0000000000..31acbfe389
--- /dev/null
+++ b/api/server/middleware/abortControllers.js
@@ -0,0 +1,2 @@
+// abortControllers.js
+module.exports = new Map();
diff --git a/api/server/middleware/abortMiddleware.js b/api/server/middleware/abortMiddleware.js
new file mode 100644
index 0000000000..1f762ca808
--- /dev/null
+++ b/api/server/middleware/abortMiddleware.js
@@ -0,0 +1,392 @@
+const { logger } = require('@librechat/data-schemas');
+const { countTokens, isEnabled, sendEvent, sanitizeMessageForTransmit } = require('@librechat/api');
+const { isAssistantsEndpoint, ErrorTypes, Constants } = require('librechat-data-provider');
+const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
+const clearPendingReq = require('~/cache/clearPendingReq');
+const { sendError } = require('~/server/middleware/error');
+const { spendTokens } = require('~/models/spendTokens');
+const abortControllers = require('./abortControllers');
+const { saveMessage, getConvo } = require('~/models');
+const { abortRun } = require('./abortRun');
+
+const abortDataMap = new WeakMap();
+
+/**
+ * @param {string} abortKey
+ * @returns {boolean}
+ */
+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;
+}
+
+/**
+ * @param {string} abortKey
+ * @returns {function(): void}
+ */
+function createCleanUpHandler(abortKey) {
+ return function () {
+ try {
+ cleanupAbortController(abortKey);
+ } catch {
+ // Ignore cleanup errors
+ }
+ };
+}
+
+async function abortMessage(req, res) {
+ let { abortKey, endpoint } = req.body;
+
+ if (isAssistantsEndpoint(endpoint)) {
+ return await abortRun(req, res);
+ }
+
+ const conversationId = abortKey?.split(':')?.[0] ?? req.user.id;
+
+ if (!abortControllers.has(abortKey) && abortControllers.has(conversationId)) {
+ abortKey = conversationId;
+ }
+
+ if (!abortControllers.has(abortKey) && !res.headersSent) {
+ return res.status(204).send({ message: 'Request not found' });
+ }
+
+ const { abortController } = abortControllers.get(abortKey) ?? {};
+ if (!abortController) {
+ return res.status(204).send({ message: 'Request not found' });
+ }
+
+ const finalEvent = await abortController.abortCompletion?.();
+ logger.debug(
+ `[abortMessage] ID: ${req.user.id} | ${req.user.email} | Aborted request: ` +
+ JSON.stringify({ abortKey }),
+ );
+ cleanupAbortController(abortKey);
+
+ if (res.headersSent && finalEvent) {
+ return sendEvent(res, finalEvent);
+ }
+
+ res.setHeader('Content-Type', 'application/json');
+ res.send(JSON.stringify(finalEvent));
+}
+
+const handleAbort = function () {
+ return async function (req, res) {
+ try {
+ if (isEnabled(process.env.LIMIT_CONCURRENT_MESSAGES)) {
+ await clearPendingReq({ userId: req.user.id });
+ }
+ return await abortMessage(req, res);
+ } catch (err) {
+ logger.error('[abortMessage] handleAbort error', err);
+ }
+ };
+};
+
+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 () {
+ 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 {};
+ }
+ };
+
+ /**
+ * @param {TMessage} userMessage
+ * @param {string} responseMessageId
+ * @param {boolean} [isNewConvo]
+ */
+ const onStart = (userMessage, responseMessageId, isNewConvo) => {
+ sendEvent(res, { message: userMessage, created: true });
+
+ const prelimAbortKey = userMessage?.conversationId ?? req.user.id;
+ const abortKey = isNewConvo
+ ? `${prelimAbortKey}${Constants.COMMON_DIVIDER}${Constants.NEW_CONVO}`
+ : prelimAbortKey;
+ getReqData({ abortKey });
+ const prevRequest = abortControllers.get(abortKey);
+ const { overrideUserMessageId } = req?.body ?? {};
+
+ if (overrideUserMessageId != null && prevRequest && prevRequest?.abortController) {
+ const data = prevRequest.abortController.getAbortData();
+ getReqData({ userMessage: data?.userMessage });
+ const addedAbortKey = `${abortKey}:${responseMessageId}`;
+
+ // Store minimal options
+ const minimalOptions = {
+ endpoint: endpointOption.endpoint,
+ iconURL: endpointOption.iconURL,
+ model: endpointOption.modelOptions?.model || endpointOption.model_parameters?.model,
+ };
+
+ abortControllers.set(addedAbortKey, { abortController, ...minimalOptions });
+ const cleanupHandler = createCleanUpHandler(addedAbortKey);
+ res.on('finish', cleanupHandler);
+ return;
+ }
+
+ // Store minimal options
+ const minimalOptions = {
+ endpoint: endpointOption.endpoint,
+ iconURL: endpointOption.iconURL,
+ model: endpointOption.modelOptions?.model || endpointOption.model_parameters?.model,
+ };
+
+ abortControllers.set(abortKey, { abortController, ...minimalOptions });
+ const cleanupHandler = createCleanUpHandler(abortKey);
+ res.on('finish', cleanupHandler);
+ };
+
+ // Define abortCompletion without capturing the entire parent scope
+ abortController.abortCompletion = async function () {
+ 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 } =
+ ctrlData.getAbortDataFn();
+
+ const completionTokens = await countTokens(responseData?.text ?? '');
+ const user = ctrlData.userId;
+
+ const responseMessage = {
+ ...responseData,
+ conversationId,
+ finish_reason: 'incomplete',
+ endpoint: ctrlData.endpoint,
+ iconURL: ctrlData.iconURL,
+ model: ctrlData.modelOptions?.model ?? ctrlData.model_parameters?.model,
+ unfinished: false,
+ error: false,
+ isCreatedByUser: false,
+ tokenCount: completionTokens,
+ };
+
+ await spendTokens(
+ { ...responseMessage, context: 'incomplete', user },
+ { promptTokens, completionTokens },
+ );
+
+ await saveMessage(
+ req,
+ { ...responseMessage, user },
+ { context: 'api/server/middleware/abortMiddleware.js' },
+ );
+
+ let conversation;
+ if (userMessagePromise) {
+ const resolved = await userMessagePromise;
+ conversation = resolved?.conversation;
+ // Break reference to promise
+ resolved.conversation = null;
+ }
+
+ if (!conversation) {
+ conversation = await getConvo(user, conversationId);
+ }
+
+ return {
+ title: conversation && !conversation.title ? null : conversation?.title || 'New Chat',
+ final: true,
+ conversation,
+ requestMessage: sanitizeMessageForTransmit(userMessage),
+ responseMessage: responseMessage,
+ };
+ };
+
+ return { abortController, onStart };
+};
+
+/**
+ * @param {ServerResponse} res
+ * @param {ServerRequest} req
+ * @param {Error | unknown} error
+ * @param {Partial & { partialText?: string }} data
+ * @returns { Promise }
+ */
+const handleAbortError = async (res, req, error, data) => {
+ if (error?.message?.includes('base64')) {
+ logger.error('[handleAbortError] Error in base64 encoding', {
+ ...error,
+ stack: smartTruncateText(error?.stack, 1000),
+ message: truncateText(error.message, 350),
+ });
+ } else {
+ logger.error('[handleAbortError] AI response error; aborting request:', error);
+ }
+ const { sender, conversationId, messageId, parentMessageId, userMessageId, partialText } = data;
+
+ if (error.stack && error.stack.includes('google')) {
+ logger.warn(
+ `AI Response error for conversation ${conversationId} likely caused by Google censor/filter`,
+ );
+ }
+
+ let errorText = error?.message?.includes('"type"')
+ ? error.message
+ : 'An error occurred while processing your request. Please contact the Admin.';
+
+ if (error?.type === ErrorTypes.INVALID_REQUEST) {
+ errorText = `{"type":"${ErrorTypes.INVALID_REQUEST}"}`;
+ }
+
+ if (error?.message?.includes("does not support 'system'")) {
+ errorText = `{"type":"${ErrorTypes.NO_SYSTEM_MESSAGES}"}`;
+ }
+
+ /**
+ * @param {string} partialText
+ * @returns {Promise}
+ */
+ const respondWithError = async (partialText) => {
+ const endpointOption = req.body?.endpointOption;
+ let options = {
+ sender,
+ messageId,
+ conversationId,
+ parentMessageId,
+ text: errorText,
+ user: req.user.id,
+ spec: endpointOption?.spec,
+ iconURL: endpointOption?.iconURL,
+ modelLabel: endpointOption?.modelLabel,
+ shouldSaveMessage: userMessageId != null,
+ model: endpointOption?.modelOptions?.model || req.body?.model,
+ };
+
+ if (req.body?.agent_id) {
+ options.agent_id = req.body.agent_id;
+ }
+
+ if (partialText) {
+ options = {
+ ...options,
+ error: false,
+ unfinished: true,
+ text: partialText,
+ };
+ }
+
+ const callback = createCleanUpHandler(conversationId);
+ await sendError(req, res, options, callback);
+ };
+
+ if (partialText && partialText.length > 5) {
+ try {
+ return await abortMessage(req, res);
+ } catch (err) {
+ logger.error('[handleAbortError] error while trying to abort message', err);
+ return respondWithError(partialText);
+ }
+ } else {
+ return respondWithError();
+ }
+};
+
+module.exports = {
+ handleAbort,
+ handleAbortError,
+ createAbortController,
+ cleanupAbortController,
+};
diff --git a/api/server/middleware/abortRun.js b/api/server/middleware/abortRun.js
new file mode 100644
index 0000000000..44375f5024
--- /dev/null
+++ b/api/server/middleware/abortRun.js
@@ -0,0 +1,105 @@
+const { sendEvent } = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+const { CacheKeys, RunStatus, isUUID } = require('librechat-data-provider');
+const { initializeClient } = require('~/server/services/Endpoints/assistants');
+const { checkMessageGaps, recordUsage } = require('~/server/services/Threads');
+const { deleteMessages } = require('~/models/Message');
+const { getConvo } = require('~/models/Conversation');
+const getLogStores = require('~/cache/getLogStores');
+
+const three_minutes = 1000 * 60 * 3;
+
+async function abortRun(req, res) {
+ res.setHeader('Content-Type', 'application/json');
+ const { abortKey, endpoint } = req.body;
+ const [conversationId, latestMessageId] = abortKey.split(':');
+ const conversation = await getConvo(req.user.id, conversationId);
+
+ if (conversation?.model) {
+ req.body = req.body || {}; // Express 5: ensure req.body exists
+ req.body.model = conversation.model;
+ }
+
+ if (!isUUID.safeParse(conversationId).success) {
+ logger.error('[abortRun] Invalid conversationId', { conversationId });
+ return res.status(400).send({ message: 'Invalid conversationId' });
+ }
+
+ const cacheKey = `${req.user.id}:${conversationId}`;
+ const cache = getLogStores(CacheKeys.ABORT_KEYS);
+ const runValues = await cache.get(cacheKey);
+ if (!runValues) {
+ logger.warn('[abortRun] Run not found in cache', { cacheKey });
+ return res.status(204).send({ message: 'Run not found' });
+ }
+ const [thread_id, run_id] = runValues.split(':');
+
+ if (!run_id) {
+ logger.warn("[abortRun] Couldn't find run for cancel request", { thread_id });
+ return res.status(204).send({ message: 'Run not found' });
+ } else if (run_id === 'cancelled') {
+ logger.warn('[abortRun] Run already cancelled', { thread_id });
+ return res.status(204).send({ message: 'Run already cancelled' });
+ }
+
+ let runMessages = [];
+ /** @type {{ openai: OpenAI }} */
+ const { openai } = await initializeClient({ req, res });
+
+ try {
+ await cache.set(cacheKey, 'cancelled', three_minutes);
+ const cancelledRun = await openai.beta.threads.runs.cancel(run_id, { thread_id });
+ logger.debug('[abortRun] Cancelled run:', cancelledRun);
+ } catch (error) {
+ logger.error('[abortRun] Error cancelling run', error);
+ if (
+ error?.message?.includes(RunStatus.CANCELLED) ||
+ error?.message?.includes(RunStatus.CANCELLING)
+ ) {
+ return res.end();
+ }
+ }
+
+ try {
+ const run = await openai.beta.threads.runs.retrieve(run_id, { thread_id });
+ await recordUsage({
+ ...run.usage,
+ model: run.model,
+ user: req.user.id,
+ conversationId,
+ });
+ } catch (error) {
+ logger.error('[abortRun] Error fetching or processing run', error);
+ }
+
+ /* TODO: a reconciling strategy between the existing intermediate message would be more optimal than deleting it */
+ await deleteMessages({
+ user: req.user.id,
+ unfinished: true,
+ conversationId,
+ });
+ runMessages = await checkMessageGaps({
+ openai,
+ run_id,
+ endpoint,
+ thread_id,
+ conversationId,
+ latestMessageId,
+ });
+
+ const finalEvent = {
+ final: true,
+ conversation,
+ runMessages,
+ };
+
+ if (res.headersSent && finalEvent) {
+ return sendEvent(res, finalEvent);
+ }
+
+ res.json(finalEvent);
+}
+
+module.exports = {
+ abortRun,
+};
diff --git a/api/server/middleware/accessResources/canAccessAgentFromBody.js b/api/server/middleware/accessResources/canAccessAgentFromBody.js
new file mode 100644
index 0000000000..e2b20d4886
--- /dev/null
+++ b/api/server/middleware/accessResources/canAccessAgentFromBody.js
@@ -0,0 +1,97 @@
+const { logger } = require('@librechat/data-schemas');
+const { Constants, isAgentsEndpoint, ResourceType } = require('librechat-data-provider');
+const { canAccessResource } = require('./canAccessResource');
+const { getAgent } = require('~/models/Agent');
+
+/**
+ * Agent ID resolver function for agent_id from request body
+ * Resolves custom agent ID (e.g., "agent_abc123") to MongoDB ObjectId
+ * This is used specifically for chat routes where agent_id comes from request body
+ *
+ * @param {string} agentCustomId - Custom agent ID from request body
+ * @returns {Promise} Agent document with _id field, or null if not found
+ */
+const resolveAgentIdFromBody = async (agentCustomId) => {
+ // Handle ephemeral agents - they don't need permission checks
+ if (agentCustomId === Constants.EPHEMERAL_AGENT_ID) {
+ return null; // No permission check needed for ephemeral agents
+ }
+
+ return await getAgent({ id: agentCustomId });
+};
+
+/**
+ * Middleware factory that creates middleware to check agent access permissions from request body.
+ * This middleware is specifically designed for chat routes where the agent_id comes from req.body
+ * instead of route parameters.
+ *
+ * @param {Object} options - Configuration options
+ * @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
+ * @returns {Function} Express middleware function
+ *
+ * @example
+ * // Basic usage for agent chat (requires VIEW permission)
+ * router.post('/chat',
+ * canAccessAgentFromBody({ requiredPermission: PermissionBits.VIEW }),
+ * buildEndpointOption,
+ * chatController
+ * );
+ */
+const canAccessAgentFromBody = (options) => {
+ const { requiredPermission } = options;
+
+ // Validate required options
+ if (!requiredPermission || typeof requiredPermission !== 'number') {
+ throw new Error('canAccessAgentFromBody: requiredPermission is required and must be a number');
+ }
+
+ return async (req, res, next) => {
+ try {
+ const { endpoint, agent_id } = req.body;
+ let agentId = agent_id;
+
+ if (!isAgentsEndpoint(endpoint)) {
+ agentId = Constants.EPHEMERAL_AGENT_ID;
+ }
+
+ if (!agentId) {
+ return res.status(400).json({
+ error: 'Bad Request',
+ message: 'agent_id is required in request body',
+ });
+ }
+
+ // Skip permission checks for ephemeral agents
+ if (agentId === Constants.EPHEMERAL_AGENT_ID) {
+ return next();
+ }
+
+ const agentAccessMiddleware = canAccessResource({
+ resourceType: ResourceType.AGENT,
+ requiredPermission,
+ resourceIdParam: 'agent_id', // This will be ignored since we use custom resolver
+ idResolver: () => resolveAgentIdFromBody(agentId),
+ });
+
+ const tempReq = {
+ ...req,
+ params: {
+ ...req.params,
+ agent_id: agentId,
+ },
+ };
+
+ return agentAccessMiddleware(tempReq, res, next);
+ } catch (error) {
+ logger.error('Failed to validate agent access permissions', error);
+ return res.status(500).json({
+ error: 'Internal Server Error',
+ message: 'Failed to validate agent access permissions',
+ });
+ }
+ };
+};
+
+module.exports = {
+ canAccessAgentFromBody,
+};
diff --git a/api/server/middleware/accessResources/canAccessAgentResource.js b/api/server/middleware/accessResources/canAccessAgentResource.js
new file mode 100644
index 0000000000..62d9f248c0
--- /dev/null
+++ b/api/server/middleware/accessResources/canAccessAgentResource.js
@@ -0,0 +1,59 @@
+const { ResourceType } = require('librechat-data-provider');
+const { canAccessResource } = require('./canAccessResource');
+const { getAgent } = require('~/models/Agent');
+
+/**
+ * Agent ID resolver function
+ * Resolves custom agent ID (e.g., "agent_abc123") to MongoDB ObjectId
+ *
+ * @param {string} agentCustomId - Custom agent ID from route parameter
+ * @returns {Promise} Agent document with _id field, or null if not found
+ */
+const resolveAgentId = async (agentCustomId) => {
+ return await getAgent({ id: agentCustomId });
+};
+
+/**
+ * Agent-specific middleware factory that creates middleware to check agent access permissions.
+ * This middleware extends the generic canAccessResource to handle agent custom ID resolution.
+ *
+ * @param {Object} options - Configuration options
+ * @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
+ * @param {string} [options.resourceIdParam='id'] - The name of the route parameter containing the agent custom ID
+ * @returns {Function} Express middleware function
+ *
+ * @example
+ * // Basic usage for viewing agents
+ * router.get('/agents/:id',
+ * canAccessAgentResource({ requiredPermission: 1 }),
+ * getAgent
+ * );
+ *
+ * @example
+ * // Custom resource ID parameter and edit permission
+ * router.patch('/agents/:agent_id',
+ * canAccessAgentResource({
+ * requiredPermission: 2,
+ * resourceIdParam: 'agent_id'
+ * }),
+ * updateAgent
+ * );
+ */
+const canAccessAgentResource = (options) => {
+ const { requiredPermission, resourceIdParam = 'id' } = options;
+
+ if (!requiredPermission || typeof requiredPermission !== 'number') {
+ throw new Error('canAccessAgentResource: requiredPermission is required and must be a number');
+ }
+
+ return canAccessResource({
+ resourceType: ResourceType.AGENT,
+ requiredPermission,
+ resourceIdParam,
+ idResolver: resolveAgentId,
+ });
+};
+
+module.exports = {
+ canAccessAgentResource,
+};
diff --git a/api/server/middleware/accessResources/canAccessAgentResource.spec.js b/api/server/middleware/accessResources/canAccessAgentResource.spec.js
new file mode 100644
index 0000000000..e3dca73bd2
--- /dev/null
+++ b/api/server/middleware/accessResources/canAccessAgentResource.spec.js
@@ -0,0 +1,385 @@
+const mongoose = require('mongoose');
+const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { canAccessAgentResource } = require('./canAccessAgentResource');
+const { User, Role, AclEntry } = require('~/db/models');
+const { createAgent } = require('~/models/Agent');
+
+describe('canAccessAgentResource middleware', () => {
+ let mongoServer;
+ let req, res, next;
+ let testUser;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ await mongoose.connection.dropDatabase();
+ await Role.create({
+ name: 'test-role',
+ permissions: {
+ AGENTS: {
+ USE: true,
+ CREATE: true,
+ SHARED_GLOBAL: false,
+ },
+ },
+ });
+
+ // Create a test user
+ testUser = await User.create({
+ email: 'test@example.com',
+ name: 'Test User',
+ username: 'testuser',
+ role: 'test-role',
+ });
+
+ req = {
+ user: { id: testUser._id, role: testUser.role },
+ params: {},
+ };
+ res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+ next = jest.fn();
+
+ jest.clearAllMocks();
+ });
+
+ describe('middleware factory', () => {
+ test('should throw error if requiredPermission is not provided', () => {
+ expect(() => canAccessAgentResource({})).toThrow(
+ 'canAccessAgentResource: requiredPermission is required and must be a number',
+ );
+ });
+
+ test('should throw error if requiredPermission is not a number', () => {
+ expect(() => canAccessAgentResource({ requiredPermission: '1' })).toThrow(
+ 'canAccessAgentResource: requiredPermission is required and must be a number',
+ );
+ });
+
+ test('should create middleware with default resourceIdParam', () => {
+ const middleware = canAccessAgentResource({ requiredPermission: 1 });
+ expect(typeof middleware).toBe('function');
+ expect(middleware.length).toBe(3); // Express middleware signature
+ });
+
+ test('should create middleware with custom resourceIdParam', () => {
+ const middleware = canAccessAgentResource({
+ requiredPermission: 2,
+ resourceIdParam: 'agent_id',
+ });
+ expect(typeof middleware).toBe('function');
+ expect(middleware.length).toBe(3);
+ });
+ });
+
+ describe('permission checking with real agents', () => {
+ test('should allow access when user is the agent author', async () => {
+ // Create an agent owned by the test user
+ const agent = await createAgent({
+ id: `agent_${Date.now()}`,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: testUser._id,
+ });
+
+ // Create ACL entry for the author (owner permissions)
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: 15, // All permissions (1+2+4+8)
+ grantedBy: testUser._id,
+ });
+
+ req.params.id = agent.id;
+
+ const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('should deny access when user is not the author and has no ACL entry', async () => {
+ // Create an agent owned by a different user
+ const otherUser = await User.create({
+ email: 'other@example.com',
+ name: 'Other User',
+ username: 'otheruser',
+ role: 'test-role',
+ });
+
+ const agent = await createAgent({
+ id: `agent_${Date.now()}`,
+ name: 'Other User Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ });
+
+ // Create ACL entry for the other user (owner)
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: otherUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: 15, // All permissions
+ grantedBy: otherUser._id,
+ });
+
+ req.params.id = agent.id;
+
+ const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to access this agent',
+ });
+ });
+
+ test('should allow access when user has ACL entry with sufficient permissions', async () => {
+ // Create an agent owned by a different user
+ const otherUser = await User.create({
+ email: 'other2@example.com',
+ name: 'Other User 2',
+ username: 'otheruser2',
+ role: 'test-role',
+ });
+
+ const agent = await createAgent({
+ id: `agent_${Date.now()}`,
+ name: 'Shared Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ });
+
+ // Create ACL entry granting view permission to test user
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: 1, // VIEW permission
+ grantedBy: otherUser._id,
+ });
+
+ req.params.id = agent.id;
+
+ const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('should deny access when ACL permissions are insufficient', async () => {
+ // Create an agent owned by a different user
+ const otherUser = await User.create({
+ email: 'other3@example.com',
+ name: 'Other User 3',
+ username: 'otheruser3',
+ role: 'test-role',
+ });
+
+ const agent = await createAgent({
+ id: `agent_${Date.now()}`,
+ name: 'Limited Access Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ });
+
+ // Create ACL entry granting only view permission
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: 1, // VIEW permission only
+ grantedBy: otherUser._id,
+ });
+
+ req.params.id = agent.id;
+
+ const middleware = canAccessAgentResource({ requiredPermission: 2 }); // EDIT permission required
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to access this agent',
+ });
+ });
+
+ test('should handle non-existent agent', async () => {
+ req.params.id = 'agent_nonexistent';
+
+ const middleware = canAccessAgentResource({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Not Found',
+ message: 'agent not found',
+ });
+ });
+
+ test('should use custom resourceIdParam', async () => {
+ const agent = await createAgent({
+ id: `agent_${Date.now()}`,
+ name: 'Custom Param Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: testUser._id,
+ });
+
+ // Create ACL entry for the author
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: 15, // All permissions
+ grantedBy: testUser._id,
+ });
+
+ req.params.agent_id = agent.id; // Using custom param name
+
+ const middleware = canAccessAgentResource({
+ requiredPermission: 1,
+ resourceIdParam: 'agent_id',
+ });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('permission levels', () => {
+ let agent;
+
+ beforeEach(async () => {
+ agent = await createAgent({
+ id: `agent_${Date.now()}`,
+ name: 'Permission Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: testUser._id,
+ });
+
+ // Create ACL entry with all permissions for the owner
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: 15, // All permissions (1+2+4+8)
+ grantedBy: testUser._id,
+ });
+
+ req.params.id = agent.id;
+ });
+
+ test('should support view permission (1)', async () => {
+ const middleware = canAccessAgentResource({ requiredPermission: 1 });
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should support edit permission (2)', async () => {
+ const middleware = canAccessAgentResource({ requiredPermission: 2 });
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should support delete permission (4)', async () => {
+ const middleware = canAccessAgentResource({ requiredPermission: 4 });
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should support share permission (8)', async () => {
+ const middleware = canAccessAgentResource({ requiredPermission: 8 });
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should support combined permissions', async () => {
+ const viewAndEdit = 1 | 2; // 3
+ const middleware = canAccessAgentResource({ requiredPermission: viewAndEdit });
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+ });
+
+ describe('integration with agent operations', () => {
+ test('should work with agent CRUD operations', async () => {
+ const agentId = `agent_${Date.now()}`;
+
+ // Create agent
+ const agent = await createAgent({
+ id: agentId,
+ name: 'Integration Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: testUser._id,
+ description: 'Testing integration',
+ });
+
+ // Create ACL entry for the author
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: 15, // All permissions
+ grantedBy: testUser._id,
+ });
+
+ req.params.id = agentId;
+
+ // Test view access
+ const viewMiddleware = canAccessAgentResource({ requiredPermission: 1 });
+ await viewMiddleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ jest.clearAllMocks();
+
+ // Update the agent
+ const { updateAgent } = require('~/models/Agent');
+ await updateAgent({ id: agentId }, { description: 'Updated description' });
+
+ // Test edit access
+ const editMiddleware = canAccessAgentResource({ requiredPermission: 2 });
+ await editMiddleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/api/server/middleware/accessResources/canAccessMCPServerResource.js b/api/server/middleware/accessResources/canAccessMCPServerResource.js
new file mode 100644
index 0000000000..69f5f4e4f6
--- /dev/null
+++ b/api/server/middleware/accessResources/canAccessMCPServerResource.js
@@ -0,0 +1,61 @@
+const { ResourceType } = require('librechat-data-provider');
+const { canAccessResource } = require('./canAccessResource');
+const { findMCPServerById } = require('~/models');
+
+/**
+ * MCP Server ID resolver function
+ * Resolves custom MCP server ID (e.g., "mcp_abc123") to MongoDB ObjectId
+ *
+ * @param {string} mcpServerCustomId - Custom MCP server ID from route parameter
+ * @returns {Promise} MCP server document with _id field, or null if not found
+ */
+const resolveMCPServerId = async (mcpServerCustomId) => {
+ return await findMCPServerById(mcpServerCustomId);
+};
+
+/**
+ * MCP Server-specific middleware factory that creates middleware to check MCP server access permissions.
+ * This middleware extends the generic canAccessResource to handle MCP server custom ID resolution.
+ *
+ * @param {Object} options - Configuration options
+ * @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
+ * @param {string} [options.resourceIdParam='serverName'] - The name of the route parameter containing the MCP server custom ID
+ * @returns {Function} Express middleware function
+ *
+ * @example
+ * // Basic usage for viewing MCP servers
+ * router.get('/servers/:serverName',
+ * canAccessMCPServerResource({ requiredPermission: 1 }),
+ * getMCPServer
+ * );
+ *
+ * @example
+ * // Custom resource ID parameter and edit permission
+ * router.patch('/servers/:id',
+ * canAccessMCPServerResource({
+ * requiredPermission: 2,
+ * resourceIdParam: 'id'
+ * }),
+ * updateMCPServer
+ * );
+ */
+const canAccessMCPServerResource = (options) => {
+ const { requiredPermission, resourceIdParam = 'serverName' } = options;
+
+ if (!requiredPermission || typeof requiredPermission !== 'number') {
+ throw new Error(
+ 'canAccessMCPServerResource: requiredPermission is required and must be a number',
+ );
+ }
+
+ return canAccessResource({
+ resourceType: ResourceType.MCPSERVER,
+ requiredPermission,
+ resourceIdParam,
+ idResolver: resolveMCPServerId,
+ });
+};
+
+module.exports = {
+ canAccessMCPServerResource,
+};
diff --git a/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js b/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js
new file mode 100644
index 0000000000..5eef1438ff
--- /dev/null
+++ b/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js
@@ -0,0 +1,627 @@
+const mongoose = require('mongoose');
+const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { canAccessMCPServerResource } = require('./canAccessMCPServerResource');
+const { User, Role, AclEntry } = require('~/db/models');
+const { createMCPServer } = require('~/models');
+
+describe('canAccessMCPServerResource middleware', () => {
+ let mongoServer;
+ let req, res, next;
+ let testUser;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ await mongoose.connection.dropDatabase();
+ await Role.create({
+ name: 'test-role',
+ permissions: {
+ MCPSERVERS: {
+ USE: true,
+ CREATE: true,
+ SHARED_GLOBAL: false,
+ },
+ },
+ });
+
+ // Create a test user
+ testUser = await User.create({
+ email: 'test@example.com',
+ name: 'Test User',
+ username: 'testuser',
+ role: 'test-role',
+ });
+
+ req = {
+ user: { id: testUser._id, role: testUser.role },
+ params: {},
+ };
+ res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+ next = jest.fn();
+
+ jest.clearAllMocks();
+ });
+
+ describe('middleware factory', () => {
+ test('should throw error if requiredPermission is not provided', () => {
+ expect(() => canAccessMCPServerResource({})).toThrow(
+ 'canAccessMCPServerResource: requiredPermission is required and must be a number',
+ );
+ });
+
+ test('should throw error if requiredPermission is not a number', () => {
+ expect(() => canAccessMCPServerResource({ requiredPermission: '1' })).toThrow(
+ 'canAccessMCPServerResource: requiredPermission is required and must be a number',
+ );
+ });
+
+ test('should throw error if requiredPermission is null', () => {
+ expect(() => canAccessMCPServerResource({ requiredPermission: null })).toThrow(
+ 'canAccessMCPServerResource: requiredPermission is required and must be a number',
+ );
+ });
+
+ test('should create middleware with default resourceIdParam (serverName)', () => {
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
+ expect(typeof middleware).toBe('function');
+ expect(middleware.length).toBe(3); // Express middleware signature
+ });
+
+ test('should create middleware with custom resourceIdParam', () => {
+ const middleware = canAccessMCPServerResource({
+ requiredPermission: 2,
+ resourceIdParam: 'mcpId',
+ });
+ expect(typeof middleware).toBe('function');
+ expect(middleware.length).toBe(3);
+ });
+ });
+
+ describe('permission checking with real MCP servers', () => {
+ test('should allow access when user is the MCP server author', async () => {
+ // Create an MCP server owned by the test user
+ const mcpServer = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp',
+ title: 'Test MCP Server',
+ },
+ author: testUser._id,
+ });
+
+ // Create ACL entry for the author (owner permissions)
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer._id,
+ permBits: 15, // All permissions (1+2+4+8)
+ grantedBy: testUser._id,
+ });
+
+ req.params.serverName = mcpServer.serverName;
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 }); // VIEW permission
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('should deny access when user is not the author and has no ACL entry', async () => {
+ // Create an MCP server owned by a different user
+ const otherUser = await User.create({
+ email: 'other@example.com',
+ name: 'Other User',
+ username: 'otheruser',
+ role: 'test-role',
+ });
+
+ const mcpServer = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp',
+ title: 'Other User MCP Server',
+ },
+ author: otherUser._id,
+ });
+
+ // Create ACL entry for the other user (owner)
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: otherUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer._id,
+ permBits: 15, // All permissions
+ grantedBy: otherUser._id,
+ });
+
+ req.params.serverName = mcpServer.serverName;
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 }); // VIEW permission
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to access this mcpServer',
+ });
+ });
+
+ test('should allow access when user has ACL entry with sufficient permissions', async () => {
+ // Create an MCP server owned by a different user
+ const otherUser = await User.create({
+ email: 'other2@example.com',
+ name: 'Other User 2',
+ username: 'otheruser2',
+ role: 'test-role',
+ });
+
+ const mcpServer = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp',
+ title: 'Shared MCP Server',
+ },
+ author: otherUser._id,
+ });
+
+ // Create ACL entry granting view permission to test user
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer._id,
+ permBits: 1, // VIEW permission
+ grantedBy: otherUser._id,
+ });
+
+ req.params.serverName = mcpServer.serverName;
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 }); // VIEW permission
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('should deny access when ACL permissions are insufficient', async () => {
+ // Create an MCP server owned by a different user
+ const otherUser = await User.create({
+ email: 'other3@example.com',
+ name: 'Other User 3',
+ username: 'otheruser3',
+ role: 'test-role',
+ });
+
+ const mcpServer = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp',
+ title: 'Limited Access MCP Server',
+ },
+ author: otherUser._id,
+ });
+
+ // Create ACL entry granting only view permission
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer._id,
+ permBits: 1, // VIEW permission only
+ grantedBy: otherUser._id,
+ });
+
+ req.params.serverName = mcpServer.serverName;
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 2 }); // EDIT permission required
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to access this mcpServer',
+ });
+ });
+
+ test('should handle non-existent MCP server', async () => {
+ req.params.serverName = 'non-existent-mcp-server';
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Not Found',
+ message: 'mcpServer not found',
+ });
+ });
+
+ test('should use custom resourceIdParam', async () => {
+ const mcpServer = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp',
+ title: 'Custom Param MCP Server',
+ },
+ author: testUser._id,
+ });
+
+ // Create ACL entry for the author
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer._id,
+ permBits: 15, // All permissions
+ grantedBy: testUser._id,
+ });
+
+ req.params.mcpId = mcpServer.serverName; // Using custom param name
+
+ const middleware = canAccessMCPServerResource({
+ requiredPermission: 1,
+ resourceIdParam: 'mcpId',
+ });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('permission levels', () => {
+ let mcpServer;
+
+ beforeEach(async () => {
+ mcpServer = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp',
+ title: 'Permission Test MCP Server',
+ },
+ author: testUser._id,
+ });
+
+ // Create ACL entry with all permissions for the owner
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer._id,
+ permBits: 15, // All permissions (1+2+4+8)
+ grantedBy: testUser._id,
+ });
+
+ req.params.serverName = mcpServer.serverName;
+ });
+
+ test('should support view permission (1)', async () => {
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should support edit permission (2)', async () => {
+ const middleware = canAccessMCPServerResource({ requiredPermission: 2 });
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should support delete permission (4)', async () => {
+ const middleware = canAccessMCPServerResource({ requiredPermission: 4 });
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should support share permission (8)', async () => {
+ const middleware = canAccessMCPServerResource({ requiredPermission: 8 });
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should support combined permissions', async () => {
+ const viewAndEdit = 1 | 2; // 3
+ const middleware = canAccessMCPServerResource({ requiredPermission: viewAndEdit });
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+ });
+
+ describe('integration with resolveMCPServerId', () => {
+ test('should resolve serverName to MongoDB ObjectId correctly', async () => {
+ const mcpServer = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp',
+ title: 'Integration Test MCP Server',
+ },
+ author: testUser._id,
+ });
+
+ // Create ACL entry for the author
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer._id,
+ permBits: 15, // All permissions
+ grantedBy: testUser._id,
+ });
+
+ req.params.serverName = mcpServer.serverName;
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ // Verify that req.resourceAccess was set correctly
+ expect(req.resourceAccess).toBeDefined();
+ expect(req.resourceAccess.resourceType).toBe(ResourceType.MCPSERVER);
+ expect(req.resourceAccess.resourceId.toString()).toBe(mcpServer._id.toString());
+ expect(req.resourceAccess.customResourceId).toBe(mcpServer.serverName);
+ });
+
+ test('should work with MCP server CRUD operations', async () => {
+ // Create MCP server
+ const mcpServer = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp',
+ title: 'CRUD Test MCP Server',
+ description: 'Testing integration',
+ },
+ author: testUser._id,
+ });
+
+ // Create ACL entry for the author
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer._id,
+ permBits: 15, // All permissions
+ grantedBy: testUser._id,
+ });
+
+ req.params.serverName = mcpServer.serverName;
+
+ // Test view access
+ const viewMiddleware = canAccessMCPServerResource({ requiredPermission: 1 });
+ await viewMiddleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ jest.clearAllMocks();
+
+ // Update the MCP server
+ const { updateMCPServer } = require('~/models');
+ await updateMCPServer(mcpServer.serverName, {
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp',
+ title: 'CRUD Test MCP Server',
+ description: 'Updated description',
+ },
+ });
+
+ // Test edit access
+ const editMiddleware = canAccessMCPServerResource({ requiredPermission: 2 });
+ await editMiddleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should handle stdio type MCP server', async () => {
+ const mcpServer = await createMCPServer({
+ config: {
+ type: 'stdio',
+ command: 'node',
+ args: ['server.js'],
+ title: 'Stdio MCP Server',
+ },
+ author: testUser._id,
+ });
+
+ // Create ACL entry for the author
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer._id,
+ permBits: 15,
+ grantedBy: testUser._id,
+ });
+
+ req.params.serverName = mcpServer.serverName;
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(req.resourceAccess.resourceInfo.config.type).toBe('stdio');
+ });
+ });
+
+ describe('authentication and authorization edge cases', () => {
+ test('should return 400 when serverName parameter is missing', async () => {
+ // Don't set req.params.serverName
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Bad Request',
+ message: 'serverName is required',
+ });
+ });
+
+ test('should return 401 when user is not authenticated', async () => {
+ req.user = null;
+ req.params.serverName = 'some-server';
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Unauthorized',
+ message: 'Authentication required',
+ });
+ });
+
+ test('should return 401 when user id is missing', async () => {
+ req.user = { role: 'test-role' }; // No id
+ req.params.serverName = 'some-server';
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Unauthorized',
+ message: 'Authentication required',
+ });
+ });
+
+ test('should allow admin users to bypass permission checks', async () => {
+ const { SystemRoles } = require('librechat-data-provider');
+
+ // Create an MCP server owned by another user
+ const otherUser = await User.create({
+ email: 'owner@example.com',
+ name: 'Owner User',
+ username: 'owneruser',
+ role: 'test-role',
+ });
+
+ const mcpServer = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp',
+ title: 'Admin Test MCP Server',
+ },
+ author: otherUser._id,
+ });
+
+ // Set user as admin
+ req.user = { id: testUser._id, role: SystemRoles.ADMIN };
+ req.params.serverName = mcpServer.serverName;
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 4 }); // DELETE permission
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('error handling', () => {
+ test('should handle server returning null gracefully (treated as not found)', async () => {
+ // When an MCP server is not found, findMCPServerById returns null
+ // which the middleware correctly handles as a 404
+ req.params.serverName = 'definitely-non-existent-server';
+
+ const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Not Found',
+ message: 'mcpServer not found',
+ });
+ });
+ });
+
+ describe('multiple servers with same title', () => {
+ test('should handle MCP servers with auto-generated suffixes', async () => {
+ // Create multiple servers with the same title (will have different serverNames)
+ const mcpServer1 = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp1',
+ title: 'Duplicate Title',
+ },
+ author: testUser._id,
+ });
+
+ const mcpServer2 = await createMCPServer({
+ config: {
+ type: 'sse',
+ url: 'https://example.com/mcp2',
+ title: 'Duplicate Title',
+ },
+ author: testUser._id,
+ });
+
+ // Create ACL entries for both
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer1._id,
+ permBits: 15,
+ grantedBy: testUser._id,
+ });
+
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.MCPSERVER,
+ resourceId: mcpServer2._id,
+ permBits: 15,
+ grantedBy: testUser._id,
+ });
+
+ // Verify they have different serverNames
+ expect(mcpServer1.serverName).toBe('duplicate-title');
+ expect(mcpServer2.serverName).toBe('duplicate-title-2');
+
+ // Test access to first server
+ req.params.serverName = mcpServer1.serverName;
+ const middleware1 = canAccessMCPServerResource({ requiredPermission: 1 });
+ await middleware1(req, res, next);
+ expect(next).toHaveBeenCalled();
+ expect(req.resourceAccess.resourceId.toString()).toBe(mcpServer1._id.toString());
+
+ jest.clearAllMocks();
+
+ // Test access to second server
+ req.params.serverName = mcpServer2.serverName;
+ const middleware2 = canAccessMCPServerResource({ requiredPermission: 1 });
+ await middleware2(req, res, next);
+ expect(next).toHaveBeenCalled();
+ expect(req.resourceAccess.resourceId.toString()).toBe(mcpServer2._id.toString());
+ });
+ });
+});
diff --git a/api/server/middleware/accessResources/canAccessPromptGroupResource.js b/api/server/middleware/accessResources/canAccessPromptGroupResource.js
new file mode 100644
index 0000000000..90aa280772
--- /dev/null
+++ b/api/server/middleware/accessResources/canAccessPromptGroupResource.js
@@ -0,0 +1,61 @@
+const { ResourceType } = require('librechat-data-provider');
+const { canAccessResource } = require('./canAccessResource');
+const { getPromptGroup } = require('~/models/Prompt');
+
+/**
+ * PromptGroup ID resolver function
+ * Resolves promptGroup ID to MongoDB ObjectId
+ *
+ * @param {string} groupId - PromptGroup ID from route parameter
+ * @returns {Promise} PromptGroup document with _id field, or null if not found
+ */
+const resolvePromptGroupId = async (groupId) => {
+ return await getPromptGroup({ _id: groupId });
+};
+
+/**
+ * PromptGroup-specific middleware factory that creates middleware to check promptGroup access permissions.
+ * This middleware extends the generic canAccessResource to handle promptGroup ID resolution.
+ *
+ * @param {Object} options - Configuration options
+ * @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
+ * @param {string} [options.resourceIdParam='groupId'] - The name of the route parameter containing the promptGroup ID
+ * @returns {Function} Express middleware function
+ *
+ * @example
+ * // Basic usage for viewing promptGroups
+ * router.get('/prompts/groups/:groupId',
+ * canAccessPromptGroupResource({ requiredPermission: 1 }),
+ * getPromptGroup
+ * );
+ *
+ * @example
+ * // Custom resource ID parameter and edit permission
+ * router.patch('/prompts/groups/:id',
+ * canAccessPromptGroupResource({
+ * requiredPermission: 2,
+ * resourceIdParam: 'id'
+ * }),
+ * updatePromptGroup
+ * );
+ */
+const canAccessPromptGroupResource = (options) => {
+ const { requiredPermission, resourceIdParam = 'groupId' } = options;
+
+ if (!requiredPermission || typeof requiredPermission !== 'number') {
+ throw new Error(
+ 'canAccessPromptGroupResource: requiredPermission is required and must be a number',
+ );
+ }
+
+ return canAccessResource({
+ resourceType: ResourceType.PROMPTGROUP,
+ requiredPermission,
+ resourceIdParam,
+ idResolver: resolvePromptGroupId,
+ });
+};
+
+module.exports = {
+ canAccessPromptGroupResource,
+};
diff --git a/api/server/middleware/accessResources/canAccessPromptViaGroup.js b/api/server/middleware/accessResources/canAccessPromptViaGroup.js
new file mode 100644
index 0000000000..0bb0a804a9
--- /dev/null
+++ b/api/server/middleware/accessResources/canAccessPromptViaGroup.js
@@ -0,0 +1,55 @@
+const { ResourceType } = require('librechat-data-provider');
+const { canAccessResource } = require('./canAccessResource');
+const { getPrompt } = require('~/models/Prompt');
+
+/**
+ * Prompt to PromptGroup ID resolver function
+ * Resolves prompt ID to its parent promptGroup ID
+ *
+ * @param {string} promptId - Prompt ID from route parameter
+ * @returns {Promise} Object with promptGroup's _id field, or null if not found
+ */
+const resolvePromptToGroupId = async (promptId) => {
+ const prompt = await getPrompt({ _id: promptId });
+ if (!prompt || !prompt.groupId) {
+ return null;
+ }
+ // Return an object with _id that matches the promptGroup ID
+ return { _id: prompt.groupId };
+};
+
+/**
+ * Middleware factory that checks promptGroup permissions when accessing individual prompts.
+ * This allows permission management at the promptGroup level while still supporting
+ * individual prompt access patterns.
+ *
+ * @param {Object} options - Configuration options
+ * @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
+ * @param {string} [options.resourceIdParam='promptId'] - The name of the route parameter containing the prompt ID
+ * @returns {Function} Express middleware function
+ *
+ * @example
+ * // Check promptGroup permissions when viewing a prompt
+ * router.get('/prompts/:promptId',
+ * canAccessPromptViaGroup({ requiredPermission: 1 }),
+ * getPrompt
+ * );
+ */
+const canAccessPromptViaGroup = (options) => {
+ const { requiredPermission, resourceIdParam = 'promptId' } = options;
+
+ if (!requiredPermission || typeof requiredPermission !== 'number') {
+ throw new Error('canAccessPromptViaGroup: requiredPermission is required and must be a number');
+ }
+
+ return canAccessResource({
+ resourceType: ResourceType.PROMPTGROUP,
+ requiredPermission,
+ resourceIdParam,
+ idResolver: resolvePromptToGroupId,
+ });
+};
+
+module.exports = {
+ canAccessPromptViaGroup,
+};
diff --git a/api/server/middleware/accessResources/canAccessResource.js b/api/server/middleware/accessResources/canAccessResource.js
new file mode 100644
index 0000000000..c8bd15ffc2
--- /dev/null
+++ b/api/server/middleware/accessResources/canAccessResource.js
@@ -0,0 +1,158 @@
+const { logger } = require('@librechat/data-schemas');
+const { SystemRoles } = require('librechat-data-provider');
+const { checkPermission } = require('~/server/services/PermissionService');
+
+/**
+ * Generic base middleware factory that creates middleware to check resource access permissions.
+ * This middleware expects MongoDB ObjectIds as resource identifiers for ACL permission checks.
+ *
+ * @param {Object} options - Configuration options
+ * @param {string} options.resourceType - The type of resource (e.g., 'agent', 'file', 'project')
+ * @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
+ * @param {string} [options.resourceIdParam='resourceId'] - The name of the route parameter containing the resource ID
+ * @param {Function} [options.idResolver] - Optional function to resolve custom IDs to ObjectIds
+ * @returns {Function} Express middleware function
+ *
+ * @example
+ * // Direct usage with ObjectId (for resources that use MongoDB ObjectId in routes)
+ * router.get('/prompts/:promptId',
+ * canAccessResource({ resourceType: 'prompt', requiredPermission: 1 }),
+ * getPrompt
+ * );
+ *
+ * @example
+ * // Usage with custom ID resolver (for resources that use custom string IDs)
+ * router.get('/agents/:id',
+ * canAccessResource({
+ * resourceType: 'agent',
+ * requiredPermission: 1,
+ * resourceIdParam: 'id',
+ * idResolver: (customId) => resolveAgentId(customId)
+ * }),
+ * getAgent
+ * );
+ */
+const canAccessResource = (options) => {
+ const {
+ resourceType,
+ requiredPermission,
+ resourceIdParam = 'resourceId',
+ idResolver = null,
+ } = options;
+
+ if (!resourceType || typeof resourceType !== 'string') {
+ throw new Error('canAccessResource: resourceType is required and must be a string');
+ }
+
+ if (!requiredPermission || typeof requiredPermission !== 'number') {
+ throw new Error('canAccessResource: requiredPermission is required and must be a number');
+ }
+
+ return async (req, res, next) => {
+ try {
+ // Extract resource ID from route parameters
+ const rawResourceId = req.params[resourceIdParam];
+
+ if (!rawResourceId) {
+ logger.warn(`[canAccessResource] Missing ${resourceIdParam} in route parameters`);
+ return res.status(400).json({
+ error: 'Bad Request',
+ message: `${resourceIdParam} is required`,
+ });
+ }
+
+ // Check if user is authenticated
+ if (!req.user || !req.user.id) {
+ logger.warn(
+ `[canAccessResource] Unauthenticated request for ${resourceType} ${rawResourceId}`,
+ );
+ return res.status(401).json({
+ error: 'Unauthorized',
+ message: 'Authentication required',
+ });
+ }
+ // if system admin let through
+ if (req.user.role === SystemRoles.ADMIN) {
+ return next();
+ }
+ const userId = req.user.id;
+ let resourceId = rawResourceId;
+ let resourceInfo = null;
+
+ // Resolve custom ID to ObjectId if resolver is provided
+ if (idResolver) {
+ logger.debug(
+ `[canAccessResource] Resolving ${resourceType} custom ID ${rawResourceId} to ObjectId`,
+ );
+
+ const resolutionResult = await idResolver(rawResourceId);
+
+ if (!resolutionResult) {
+ logger.warn(`[canAccessResource] ${resourceType} not found: ${rawResourceId}`);
+ return res.status(404).json({
+ error: 'Not Found',
+ message: `${resourceType} not found`,
+ });
+ }
+
+ // Handle different resolver return formats
+ if (typeof resolutionResult === 'string' || resolutionResult._id) {
+ resourceId = resolutionResult._id || resolutionResult;
+ resourceInfo = typeof resolutionResult === 'object' ? resolutionResult : null;
+ } else {
+ resourceId = resolutionResult;
+ }
+
+ logger.debug(
+ `[canAccessResource] Resolved ${resourceType} ${rawResourceId} to ObjectId ${resourceId}`,
+ );
+ }
+
+ // Check permissions using PermissionService with ObjectId
+ const hasPermission = await checkPermission({
+ userId,
+ role: req.user.role,
+ resourceType,
+ resourceId,
+ requiredPermission,
+ });
+
+ if (hasPermission) {
+ logger.debug(
+ `[canAccessResource] User ${userId} has permission ${requiredPermission} on ${resourceType} ${rawResourceId} (${resourceId})`,
+ );
+
+ req.resourceAccess = {
+ resourceType,
+ resourceId, // MongoDB ObjectId for ACL operations
+ customResourceId: rawResourceId, // Original ID from route params
+ permission: requiredPermission,
+ userId,
+ ...(resourceInfo && { resourceInfo }),
+ };
+
+ return next();
+ }
+
+ logger.warn(
+ `[canAccessResource] User ${userId} denied access to ${resourceType} ${rawResourceId} ` +
+ `(required permission: ${requiredPermission})`,
+ );
+
+ return res.status(403).json({
+ error: 'Forbidden',
+ message: `Insufficient permissions to access this ${resourceType}`,
+ });
+ } catch (error) {
+ logger.error(`[canAccessResource] Error checking access for ${resourceType}:`, error);
+ return res.status(500).json({
+ error: 'Internal Server Error',
+ message: 'Failed to check resource access permissions',
+ });
+ }
+ };
+};
+
+module.exports = {
+ canAccessResource,
+};
diff --git a/api/server/middleware/accessResources/fileAccess.js b/api/server/middleware/accessResources/fileAccess.js
new file mode 100644
index 0000000000..25d41e7c02
--- /dev/null
+++ b/api/server/middleware/accessResources/fileAccess.js
@@ -0,0 +1,123 @@
+const { logger } = require('@librechat/data-schemas');
+const { PermissionBits, hasPermissions, ResourceType } = require('librechat-data-provider');
+const { getEffectivePermissions } = require('~/server/services/PermissionService');
+const { getAgents } = require('~/models/Agent');
+const { getFiles } = require('~/models');
+
+/**
+ * Checks if user has access to a file through agent permissions
+ * Files inherit permissions from agents - if you can view the agent, you can access its files
+ */
+const checkAgentBasedFileAccess = async ({ userId, role, fileId }) => {
+ try {
+ /** Agents that have this file in their tool_resources */
+ const agentsWithFile = await getAgents({
+ $or: [
+ { 'tool_resources.execute_code.file_ids': fileId },
+ { 'tool_resources.file_search.file_ids': fileId },
+ { 'tool_resources.context.file_ids': fileId },
+ { 'tool_resources.ocr.file_ids': fileId },
+ ],
+ });
+
+ if (!agentsWithFile || agentsWithFile.length === 0) {
+ return false;
+ }
+
+ // Check if user has access to any of these agents
+ for (const agent of agentsWithFile) {
+ // Check if user is the agent author
+ if (agent.author && agent.author.toString() === userId) {
+ logger.debug(`[fileAccess] User is author of agent ${agent.id}`);
+ return true;
+ }
+
+ // Check ACL permissions for VIEW access on the agent
+ try {
+ const permissions = await getEffectivePermissions({
+ userId,
+ role,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id || agent.id,
+ });
+
+ if (hasPermissions(permissions, PermissionBits.VIEW)) {
+ logger.debug(`[fileAccess] User ${userId} has VIEW permissions on agent ${agent.id}`);
+ return true;
+ }
+ } catch (permissionError) {
+ logger.warn(
+ `[fileAccess] Permission check failed for agent ${agent.id}:`,
+ permissionError.message,
+ );
+ // Continue checking other agents
+ }
+ }
+
+ return false;
+ } catch (error) {
+ logger.error('[fileAccess] Error checking agent-based access:', error);
+ return false;
+ }
+};
+
+/**
+ * Middleware to check if user can access a file
+ * Checks: 1) File ownership, 2) Agent-based access (file inherits agent permissions)
+ */
+const fileAccess = async (req, res, next) => {
+ try {
+ const fileId = req.params.file_id;
+ const userId = req.user?.id;
+ const userRole = req.user?.role;
+ if (!fileId) {
+ return res.status(400).json({
+ error: 'Bad Request',
+ message: 'file_id is required',
+ });
+ }
+
+ if (!userId) {
+ return res.status(401).json({
+ error: 'Unauthorized',
+ message: 'Authentication required',
+ });
+ }
+
+ const [file] = await getFiles({ file_id: fileId });
+ if (!file) {
+ return res.status(404).json({
+ error: 'Not Found',
+ message: 'File not found',
+ });
+ }
+
+ if (file.user && file.user.toString() === userId) {
+ req.fileAccess = { file };
+ return next();
+ }
+
+ /** Agent-based access (file inherits agent permissions) */
+ const hasAgentAccess = await checkAgentBasedFileAccess({ userId, role: userRole, fileId });
+ if (hasAgentAccess) {
+ req.fileAccess = { file };
+ return next();
+ }
+
+ logger.warn(`[fileAccess] User ${userId} denied access to file ${fileId}`);
+ return res.status(403).json({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to access this file',
+ });
+ } catch (error) {
+ logger.error('[fileAccess] Error checking file access:', error);
+ return res.status(500).json({
+ error: 'Internal Server Error',
+ message: 'Failed to check file access permissions',
+ });
+ }
+};
+
+module.exports = {
+ fileAccess,
+};
diff --git a/api/server/middleware/accessResources/fileAccess.spec.js b/api/server/middleware/accessResources/fileAccess.spec.js
new file mode 100644
index 0000000000..de7c7d50f6
--- /dev/null
+++ b/api/server/middleware/accessResources/fileAccess.spec.js
@@ -0,0 +1,483 @@
+const mongoose = require('mongoose');
+const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { fileAccess } = require('./fileAccess');
+const { User, Role, AclEntry } = require('~/db/models');
+const { createAgent } = require('~/models/Agent');
+const { createFile } = require('~/models');
+
+describe('fileAccess middleware', () => {
+ let mongoServer;
+ let req, res, next;
+ let testUser, otherUser, thirdUser;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ await mongoose.connection.dropDatabase();
+
+ // Create test role
+ await Role.create({
+ name: 'test-role',
+ permissions: {
+ AGENTS: {
+ USE: true,
+ CREATE: true,
+ SHARED_GLOBAL: false,
+ },
+ },
+ });
+
+ // Create test users
+ testUser = await User.create({
+ email: 'test@example.com',
+ name: 'Test User',
+ username: 'testuser',
+ role: 'test-role',
+ });
+
+ otherUser = await User.create({
+ email: 'other@example.com',
+ name: 'Other User',
+ username: 'otheruser',
+ role: 'test-role',
+ });
+
+ thirdUser = await User.create({
+ email: 'third@example.com',
+ name: 'Third User',
+ username: 'thirduser',
+ role: 'test-role',
+ });
+
+ // Setup request/response objects
+ req = {
+ user: { id: testUser._id.toString(), role: testUser.role },
+ params: {},
+ };
+ res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+ next = jest.fn();
+
+ jest.clearAllMocks();
+ });
+
+ describe('basic file access', () => {
+ test('should allow access when user owns the file', async () => {
+ // Create a file owned by testUser
+ await createFile({
+ user: testUser._id.toString(),
+ file_id: 'file_owned_by_user',
+ filepath: '/test/file.txt',
+ filename: 'file.txt',
+ type: 'text/plain',
+ size: 100,
+ });
+
+ req.params.file_id = 'file_owned_by_user';
+ await fileAccess(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(req.fileAccess).toBeDefined();
+ expect(req.fileAccess.file).toBeDefined();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('should deny access when user does not own the file and no agent access', async () => {
+ // Create a file owned by otherUser
+ await createFile({
+ user: otherUser._id.toString(),
+ file_id: 'file_owned_by_other',
+ filepath: '/test/file.txt',
+ filename: 'file.txt',
+ type: 'text/plain',
+ size: 100,
+ });
+
+ req.params.file_id = 'file_owned_by_other';
+ await fileAccess(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to access this file',
+ });
+ });
+
+ test('should return 404 when file does not exist', async () => {
+ req.params.file_id = 'non_existent_file';
+ await fileAccess(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Not Found',
+ message: 'File not found',
+ });
+ });
+
+ test('should return 400 when file_id is missing', async () => {
+ // Don't set file_id in params
+ await fileAccess(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Bad Request',
+ message: 'file_id is required',
+ });
+ });
+
+ test('should return 401 when user is not authenticated', async () => {
+ req.user = null;
+ req.params.file_id = 'some_file';
+
+ await fileAccess(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Unauthorized',
+ message: 'Authentication required',
+ });
+ });
+ });
+
+ describe('agent-based file access', () => {
+ beforeEach(async () => {
+ // Create a file owned by otherUser (not testUser)
+ await createFile({
+ user: otherUser._id.toString(),
+ file_id: 'shared_file_via_agent',
+ filepath: '/test/shared.txt',
+ filename: 'shared.txt',
+ type: 'text/plain',
+ size: 100,
+ });
+ });
+
+ test('should allow access when user is author of agent with file', async () => {
+ // Create agent owned by testUser with the file
+ await createAgent({
+ id: `agent_${Date.now()}`,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: testUser._id,
+ tool_resources: {
+ file_search: {
+ file_ids: ['shared_file_via_agent'],
+ },
+ },
+ });
+
+ req.params.file_id = 'shared_file_via_agent';
+ await fileAccess(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(req.fileAccess).toBeDefined();
+ expect(req.fileAccess.file).toBeDefined();
+ });
+
+ test('should allow access when user has VIEW permission on agent with file', async () => {
+ // Create agent owned by otherUser
+ const agent = await createAgent({
+ id: `agent_${Date.now()}`,
+ name: 'Shared Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ tool_resources: {
+ execute_code: {
+ file_ids: ['shared_file_via_agent'],
+ },
+ },
+ });
+
+ // Grant VIEW permission to testUser
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: 1, // VIEW permission
+ grantedBy: otherUser._id,
+ });
+
+ req.params.file_id = 'shared_file_via_agent';
+ await fileAccess(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(req.fileAccess).toBeDefined();
+ });
+
+ test('should check file in ocr tool_resources', async () => {
+ await createAgent({
+ id: `agent_ocr_${Date.now()}`,
+ name: 'OCR Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: testUser._id,
+ tool_resources: {
+ ocr: {
+ file_ids: ['shared_file_via_agent'],
+ },
+ },
+ });
+
+ req.params.file_id = 'shared_file_via_agent';
+ await fileAccess(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(req.fileAccess).toBeDefined();
+ });
+
+ test('should deny access when user has no permission on agent with file', async () => {
+ // Create agent owned by otherUser without granting permission to testUser
+ const agent = await createAgent({
+ id: `agent_${Date.now()}`,
+ name: 'Private Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ tool_resources: {
+ file_search: {
+ file_ids: ['shared_file_via_agent'],
+ },
+ },
+ });
+
+ // Create ACL entry for otherUser only (owner)
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: otherUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: 15, // All permissions
+ grantedBy: otherUser._id,
+ });
+
+ req.params.file_id = 'shared_file_via_agent';
+ await fileAccess(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+ });
+
+ describe('multiple agents with same file', () => {
+ /**
+ * This test suite verifies that when multiple agents have the same file,
+ * all agents are checked for permissions, not just the first one found.
+ * This ensures users can access files through any agent they have permission for.
+ */
+
+ test('should check ALL agents with file, not just first one', async () => {
+ // Create a file owned by someone else
+ await createFile({
+ user: otherUser._id.toString(),
+ file_id: 'multi_agent_file',
+ filepath: '/test/multi.txt',
+ filename: 'multi.txt',
+ type: 'text/plain',
+ size: 100,
+ });
+
+ // Create first agent (owned by otherUser, no access for testUser)
+ const agent1 = await createAgent({
+ id: 'agent_no_access',
+ name: 'No Access Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ tool_resources: {
+ file_search: {
+ file_ids: ['multi_agent_file'],
+ },
+ },
+ });
+
+ // Create ACL for agent1 - only otherUser has access
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: otherUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent1._id,
+ permBits: 15,
+ grantedBy: otherUser._id,
+ });
+
+ // Create second agent (owned by thirdUser, but testUser has VIEW access)
+ const agent2 = await createAgent({
+ id: 'agent_with_access',
+ name: 'Accessible Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: thirdUser._id,
+ tool_resources: {
+ file_search: {
+ file_ids: ['multi_agent_file'],
+ },
+ },
+ });
+
+ // Grant testUser VIEW access to agent2
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent2._id,
+ permBits: 1, // VIEW permission
+ grantedBy: thirdUser._id,
+ });
+
+ req.params.file_id = 'multi_agent_file';
+ await fileAccess(req, res, next);
+
+ /**
+ * Should succeed because testUser has access to agent2,
+ * even though they don't have access to agent1.
+ * The fix ensures all agents are checked, not just the first one.
+ */
+ expect(next).toHaveBeenCalled();
+ expect(req.fileAccess).toBeDefined();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('should find file in any agent tool_resources type', async () => {
+ // Create a file
+ await createFile({
+ user: otherUser._id.toString(),
+ file_id: 'multi_tool_file',
+ filepath: '/test/tool.txt',
+ filename: 'tool.txt',
+ type: 'text/plain',
+ size: 100,
+ });
+
+ // Agent 1: file in file_search (no access for testUser)
+ await createAgent({
+ id: 'agent_file_search',
+ name: 'File Search Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: otherUser._id,
+ tool_resources: {
+ file_search: {
+ file_ids: ['multi_tool_file'],
+ },
+ },
+ });
+
+ // Agent 2: same file in execute_code (testUser has access)
+ await createAgent({
+ id: 'agent_execute_code',
+ name: 'Execute Code Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: thirdUser._id,
+ tool_resources: {
+ execute_code: {
+ file_ids: ['multi_tool_file'],
+ },
+ },
+ });
+
+ // Agent 3: same file in ocr (testUser also has access)
+ await createAgent({
+ id: 'agent_ocr',
+ name: 'OCR Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: testUser._id, // testUser owns this one
+ tool_resources: {
+ ocr: {
+ file_ids: ['multi_tool_file'],
+ },
+ },
+ });
+
+ req.params.file_id = 'multi_tool_file';
+ await fileAccess(req, res, next);
+
+ /**
+ * Should succeed because testUser owns agent3,
+ * even if other agents with the file are found first.
+ */
+ expect(next).toHaveBeenCalled();
+ expect(req.fileAccess).toBeDefined();
+ });
+ });
+
+ describe('edge cases', () => {
+ test('should handle agent with empty tool_resources', async () => {
+ await createFile({
+ user: otherUser._id.toString(),
+ file_id: 'orphan_file',
+ filepath: '/test/orphan.txt',
+ filename: 'orphan.txt',
+ type: 'text/plain',
+ size: 100,
+ });
+
+ // Create agent with no files in tool_resources
+ await createAgent({
+ id: `agent_empty_${Date.now()}`,
+ name: 'Empty Resources Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: testUser._id,
+ tool_resources: {},
+ });
+
+ req.params.file_id = 'orphan_file';
+ await fileAccess(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+
+ test('should handle agent with null tool_resources', async () => {
+ await createFile({
+ user: otherUser._id.toString(),
+ file_id: 'another_orphan_file',
+ filepath: '/test/orphan2.txt',
+ filename: 'orphan2.txt',
+ type: 'text/plain',
+ size: 100,
+ });
+
+ // Create agent with null tool_resources
+ await createAgent({
+ id: `agent_null_${Date.now()}`,
+ name: 'Null Resources Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: testUser._id,
+ tool_resources: null,
+ });
+
+ req.params.file_id = 'another_orphan_file';
+ await fileAccess(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+ });
+});
diff --git a/api/server/middleware/accessResources/index.js b/api/server/middleware/accessResources/index.js
new file mode 100644
index 0000000000..838834919a
--- /dev/null
+++ b/api/server/middleware/accessResources/index.js
@@ -0,0 +1,15 @@
+const { canAccessResource } = require('./canAccessResource');
+const { canAccessAgentResource } = require('./canAccessAgentResource');
+const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
+const { canAccessPromptViaGroup } = require('./canAccessPromptViaGroup');
+const { canAccessPromptGroupResource } = require('./canAccessPromptGroupResource');
+const { canAccessMCPServerResource } = require('./canAccessMCPServerResource');
+
+module.exports = {
+ canAccessResource,
+ canAccessAgentResource,
+ canAccessAgentFromBody,
+ canAccessPromptViaGroup,
+ canAccessPromptGroupResource,
+ canAccessMCPServerResource,
+};
diff --git a/api/server/middleware/assistants/validate.js b/api/server/middleware/assistants/validate.js
new file mode 100644
index 0000000000..a09f1ee002
--- /dev/null
+++ b/api/server/middleware/assistants/validate.js
@@ -0,0 +1,45 @@
+const { v4 } = require('uuid');
+const { handleAbortError } = require('~/server/middleware/abortMiddleware');
+
+/**
+ * Checks if the assistant is supported or excluded
+ * @param {object} req - Express Request
+ * @param {object} req.body - The request payload.
+ * @param {object} res - Express Response
+ * @param {function} next - Express next middleware function.
+ * @returns {Promise}
+ */
+const validateAssistant = async (req, res, next) => {
+ const { endpoint, conversationId, assistant_id, messageId } = req.body;
+
+ const appConfig = req.config;
+ /** @type {Partial} */
+ const assistantsConfig = appConfig.endpoints?.[endpoint];
+ if (!assistantsConfig) {
+ return next();
+ }
+
+ const { supportedIds, excludedIds } = assistantsConfig;
+ const error = { message: 'validateAssistant: Assistant not supported' };
+
+ if (supportedIds?.length && !supportedIds.includes(assistant_id)) {
+ return await handleAbortError(res, req, error, {
+ sender: 'System',
+ conversationId,
+ messageId: v4(),
+ parentMessageId: messageId,
+ error,
+ });
+ } else if (excludedIds?.length && excludedIds.includes(assistant_id)) {
+ return await handleAbortError(res, req, error, {
+ sender: 'System',
+ conversationId,
+ messageId: v4(),
+ parentMessageId: messageId,
+ });
+ }
+
+ return next();
+};
+
+module.exports = validateAssistant;
diff --git a/api/server/middleware/assistants/validateAuthor.js b/api/server/middleware/assistants/validateAuthor.js
new file mode 100644
index 0000000000..03936444e0
--- /dev/null
+++ b/api/server/middleware/assistants/validateAuthor.js
@@ -0,0 +1,44 @@
+const { SystemRoles } = require('librechat-data-provider');
+const { getAssistant } = require('~/models/Assistant');
+
+/**
+ * Checks if the assistant is supported or excluded
+ * @param {object} params
+ * @param {object} params.req - Express Request
+ * @param {object} params.req.body - The request payload.
+ * @param {string} params.overrideEndpoint - The override endpoint
+ * @param {string} params.overrideAssistantId - The override assistant ID
+ * @param {OpenAIClient} params.openai - OpenAI API Client
+ * @returns {Promise}
+ */
+const validateAuthor = async ({ req, openai, overrideEndpoint, overrideAssistantId }) => {
+ if (req.user.role === SystemRoles.ADMIN) {
+ return;
+ }
+
+ const endpoint = overrideEndpoint ?? req.body.endpoint ?? req.query.endpoint;
+ const assistant_id =
+ overrideAssistantId ?? req.params.id ?? req.body.assistant_id ?? req.query.assistant_id;
+
+ const appConfig = req.config;
+ /** @type {Partial} */
+ const assistantsConfig = appConfig.endpoints?.[endpoint];
+ if (!assistantsConfig) {
+ return;
+ }
+
+ if (!assistantsConfig.privateAssistants) {
+ return;
+ }
+
+ const assistantDoc = await getAssistant({ assistant_id, user: req.user.id });
+ if (assistantDoc) {
+ return;
+ }
+ const assistant = await openai.beta.assistants.retrieve(assistant_id);
+ if (req.user.id !== assistant?.metadata?.author) {
+ throw new Error(`Assistant ${assistant_id} is not authored by the user.`);
+ }
+};
+
+module.exports = validateAuthor;
diff --git a/api/server/middleware/buildEndpointOption.js b/api/server/middleware/buildEndpointOption.js
new file mode 100644
index 0000000000..202bf7d921
--- /dev/null
+++ b/api/server/middleware/buildEndpointOption.js
@@ -0,0 +1,98 @@
+const { handleError } = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+const {
+ EndpointURLs,
+ EModelEndpoint,
+ isAgentsEndpoint,
+ parseCompactConvo,
+} = require('librechat-data-provider');
+const azureAssistants = require('~/server/services/Endpoints/azureAssistants');
+const assistants = require('~/server/services/Endpoints/assistants');
+const agents = require('~/server/services/Endpoints/agents');
+const { updateFilesUsage } = require('~/models');
+
+const buildFunction = {
+ [EModelEndpoint.agents]: agents.buildOptions,
+ [EModelEndpoint.assistants]: assistants.buildOptions,
+ [EModelEndpoint.azureAssistants]: azureAssistants.buildOptions,
+};
+
+async function buildEndpointOption(req, res, next) {
+ const { endpoint, endpointType } = req.body;
+ let parsedBody;
+ try {
+ parsedBody = parseCompactConvo({ endpoint, endpointType, conversation: req.body });
+ } catch (error) {
+ logger.warn(
+ `Error parsing conversation for endpoint ${endpoint}${error?.message ? `: ${error.message}` : ''}`,
+ );
+ return handleError(res, { text: 'Error parsing conversation' });
+ }
+
+ const appConfig = req.config;
+ if (appConfig.modelSpecs?.list && appConfig.modelSpecs?.enforce) {
+ /** @type {{ list: TModelSpec[] }}*/
+ const { list } = appConfig.modelSpecs;
+ const { spec } = parsedBody;
+
+ if (!spec) {
+ return handleError(res, { text: 'No model spec selected' });
+ }
+
+ const currentModelSpec = list.find((s) => s.name === spec);
+ if (!currentModelSpec) {
+ return handleError(res, { text: 'Invalid model spec' });
+ }
+
+ if (endpoint !== currentModelSpec.preset.endpoint) {
+ return handleError(res, { text: 'Model spec mismatch' });
+ }
+
+ try {
+ currentModelSpec.preset.spec = spec;
+ parsedBody = parseCompactConvo({
+ endpoint,
+ endpointType,
+ conversation: currentModelSpec.preset,
+ });
+ if (currentModelSpec.iconURL != null && currentModelSpec.iconURL !== '') {
+ parsedBody.iconURL = currentModelSpec.iconURL;
+ }
+ } catch (error) {
+ logger.error(`Error parsing model spec for endpoint ${endpoint}`, error);
+ return handleError(res, { text: 'Error parsing model spec' });
+ }
+ } else if (parsedBody.spec && appConfig.modelSpecs?.list) {
+ // Non-enforced mode: if spec is selected, derive iconURL from model spec
+ const modelSpec = appConfig.modelSpecs.list.find((s) => s.name === parsedBody.spec);
+ if (modelSpec?.iconURL) {
+ parsedBody.iconURL = modelSpec.iconURL;
+ }
+ }
+
+ try {
+ const isAgents =
+ isAgentsEndpoint(endpoint) || req.baseUrl.startsWith(EndpointURLs[EModelEndpoint.agents]);
+ const builder = isAgents
+ ? (...args) => buildFunction[EModelEndpoint.agents](req, ...args)
+ : buildFunction[endpointType ?? endpoint];
+
+ // TODO: use object params
+ req.body = req.body || {}; // Express 5: ensure req.body exists
+ req.body.endpointOption = await builder(endpoint, parsedBody, endpointType);
+
+ if (req.body.files && !isAgents) {
+ req.body.endpointOption.attachments = updateFilesUsage(req.body.files);
+ }
+
+ next();
+ } catch (error) {
+ logger.error(
+ `Error building endpoint option for endpoint ${endpoint} with type ${endpointType}`,
+ error,
+ );
+ return handleError(res, { text: 'Error building endpoint option' });
+ }
+}
+
+module.exports = buildEndpointOption;
diff --git a/api/server/middleware/canDeleteAccount.js b/api/server/middleware/canDeleteAccount.js
new file mode 100644
index 0000000000..a913495287
--- /dev/null
+++ b/api/server/middleware/canDeleteAccount.js
@@ -0,0 +1,28 @@
+const { isEnabled } = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+const { SystemRoles } = require('librechat-data-provider');
+
+/**
+ * Checks if the user can delete their account
+ *
+ * @async
+ * @function
+ * @param {Object} req - Express request object
+ * @param {Object} res - Express response object
+ * @param {Function} next - Next middleware function
+ *
+ * @returns {Promise} - Returns a Promise which when resolved calls next middleware if the user can delete their account
+ */
+
+const canDeleteAccount = async (req, res, next = () => {}) => {
+ const { user } = req;
+ const { ALLOW_ACCOUNT_DELETION = true } = process.env;
+ if (user?.role === SystemRoles.ADMIN || isEnabled(ALLOW_ACCOUNT_DELETION)) {
+ return next();
+ } else {
+ logger.error(`[User] [Delete Account] [User cannot delete account] [User: ${user?.id}]`);
+ return res.status(403).send({ message: 'You do not have permission to delete this account' });
+ }
+};
+
+module.exports = canDeleteAccount;
diff --git a/api/server/middleware/checkBan.js b/api/server/middleware/checkBan.js
new file mode 100644
index 0000000000..79804a84e1
--- /dev/null
+++ b/api/server/middleware/checkBan.js
@@ -0,0 +1,141 @@
+const { Keyv } = require('keyv');
+const uap = require('ua-parser-js');
+const { logger } = require('@librechat/data-schemas');
+const { isEnabled, keyvMongo } = require('@librechat/api');
+const { ViolationTypes } = require('librechat-data-provider');
+const { removePorts } = require('~/server/utils');
+const denyRequest = require('./denyRequest');
+const { getLogStores } = require('~/cache');
+const { findUser } = require('~/models');
+
+const banCache = new Keyv({ store: keyvMongo, namespace: ViolationTypes.BAN, ttl: 0 });
+const message = 'Your account has been temporarily banned due to violations of our service.';
+
+/**
+ * Respond to the request if the user is banned.
+ *
+ * @async
+ * @function
+ * @param {Object} req - Express Request object.
+ * @param {Object} res - Express Response object.
+ *
+ * @returns {Promise} - Returns a Promise which when resolved sends a response status of 403 with a specific message if request is not of api/agents/chat. If it is, calls `denyRequest()` function.
+ */
+const banResponse = async (req, res) => {
+ const ua = uap(req.headers['user-agent']);
+ const { baseUrl, originalUrl } = req;
+ if (!ua.browser.name) {
+ return res.status(403).json({ message });
+ } else if (baseUrl === '/api/agents' && originalUrl.startsWith('/api/agents/chat')) {
+ return await denyRequest(req, res, { type: ViolationTypes.BAN });
+ }
+
+ return res.status(403).json({ message });
+};
+
+/**
+ * Checks if the source IP or user is banned or not.
+ *
+ * @async
+ * @function
+ * @param {Object} req - Express request object.
+ * @param {Object} res - Express response object.
+ * @param {import('express').NextFunction} next - Next middleware function.
+ *
+ * @returns {Promise} - Returns a Promise which when resolved calls next middleware if user or source IP is not banned. Otherwise calls `banResponse()` and sets ban details in `banCache`.
+ */
+const checkBan = async (req, res, next = () => {}) => {
+ try {
+ const { BAN_VIOLATIONS } = process.env ?? {};
+
+ if (!isEnabled(BAN_VIOLATIONS)) {
+ return next();
+ }
+
+ req.ip = removePorts(req);
+ let userId = req.user?.id ?? req.user?._id ?? null;
+
+ if (!userId && req?.body?.email) {
+ const user = await findUser({ email: req.body.email }, '_id');
+ userId = user?._id ? user._id.toString() : userId;
+ }
+
+ if (!userId && !req.ip) {
+ return next();
+ }
+
+ let cachedIPBan;
+ let cachedUserBan;
+
+ let ipKey = '';
+ let userKey = '';
+
+ if (req.ip) {
+ ipKey = isEnabled(process.env.USE_REDIS) ? `ban_cache:ip:${req.ip}` : req.ip;
+ cachedIPBan = await banCache.get(ipKey);
+ }
+
+ if (userId) {
+ userKey = isEnabled(process.env.USE_REDIS) ? `ban_cache:user:${userId}` : userId;
+ cachedUserBan = await banCache.get(userKey);
+ }
+
+ const cachedBan = cachedIPBan || cachedUserBan;
+
+ if (cachedBan) {
+ req.banned = true;
+ return await banResponse(req, res);
+ }
+
+ const banLogs = getLogStores(ViolationTypes.BAN);
+ const duration = banLogs.opts.ttl;
+
+ if (duration <= 0) {
+ return next();
+ }
+
+ let ipBan;
+ let userBan;
+
+ if (req.ip) {
+ ipBan = await banLogs.get(req.ip);
+ }
+
+ if (userId) {
+ userBan = await banLogs.get(userId);
+ }
+
+ const isBanned = !!(ipBan || userBan);
+
+ if (!isBanned) {
+ return next();
+ }
+
+ const timeLeft = Number(isBanned.expiresAt) - Date.now();
+
+ if (timeLeft <= 0 && ipKey) {
+ await banLogs.delete(ipKey);
+ }
+
+ if (timeLeft <= 0 && userKey) {
+ await banLogs.delete(userKey);
+ return next();
+ }
+
+ if (ipKey) {
+ banCache.set(ipKey, isBanned, timeLeft);
+ }
+
+ if (userKey) {
+ banCache.set(userKey, isBanned, timeLeft);
+ }
+
+ req.banned = true;
+ return await banResponse(req, res);
+ } catch (error) {
+ logger.error('Error in checkBan middleware:', error);
+ return next(error);
+ }
+};
+
+module.exports = checkBan;
diff --git a/api/server/middleware/checkDomainAllowed.js b/api/server/middleware/checkDomainAllowed.js
new file mode 100644
index 0000000000..754eb9c127
--- /dev/null
+++ b/api/server/middleware/checkDomainAllowed.js
@@ -0,0 +1,36 @@
+const { logger } = require('@librechat/data-schemas');
+const { isEmailDomainAllowed } = require('@librechat/api');
+const { getAppConfig } = require('~/server/services/Config');
+
+/**
+ * Checks the domain's social login is allowed
+ *
+ * @async
+ * @function
+ * @param {Object} req - Express request object.
+ * @param {Object} res - Express response object.
+ * @param {Function} next - Next middleware function.
+ *
+ * @returns {Promise} - Calls next middleware if the domain's email is allowed, otherwise redirects to login
+ */
+const checkDomainAllowed = async (req, res, next) => {
+ try {
+ const email = req?.user?.email;
+ const appConfig = await getAppConfig({
+ role: req?.user?.role,
+ });
+
+ if (email && !isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
+ logger.error(`[Social Login] [Social Login not allowed] [Email: ${email}]`);
+ res.redirect('/login');
+ return;
+ }
+
+ next();
+ } catch (error) {
+ logger.error('[checkDomainAllowed] Error checking domain:', error);
+ res.redirect('/login');
+ }
+};
+
+module.exports = checkDomainAllowed;
diff --git a/api/server/middleware/checkInviteUser.js b/api/server/middleware/checkInviteUser.js
new file mode 100644
index 0000000000..42e1faba5b
--- /dev/null
+++ b/api/server/middleware/checkInviteUser.js
@@ -0,0 +1,27 @@
+const { getInvite } = require('~/models/inviteUser');
+const { deleteTokens } = require('~/models');
+
+async function checkInviteUser(req, res, next) {
+ const token = req.body.token;
+
+ if (!token || token === 'undefined') {
+ next();
+ return;
+ }
+
+ try {
+ const invite = await getInvite(token, req.body.email);
+
+ if (!invite || invite.error === true) {
+ return res.status(400).json({ message: 'Invalid invite token' });
+ }
+
+ await deleteTokens({ token: invite.token });
+ req.invite = invite;
+ next();
+ } catch (error) {
+ return res.status(429).json({ message: error.message });
+ }
+}
+
+module.exports = checkInviteUser;
diff --git a/api/server/middleware/checkPeoplePickerAccess.js b/api/server/middleware/checkPeoplePickerAccess.js
new file mode 100644
index 0000000000..0e604272db
--- /dev/null
+++ b/api/server/middleware/checkPeoplePickerAccess.js
@@ -0,0 +1,82 @@
+const { logger } = require('@librechat/data-schemas');
+const { PrincipalType, PermissionTypes, Permissions } = require('librechat-data-provider');
+const { getRoleByName } = require('~/models/Role');
+
+/**
+ * Middleware to check if user has permission to access people picker functionality
+ * Checks specific permission based on the 'type' query parameter:
+ * - type=user: requires VIEW_USERS permission
+ * - type=group: requires VIEW_GROUPS permission
+ * - type=role: requires VIEW_ROLES permission
+ * - no type (mixed search): requires either VIEW_USERS OR VIEW_GROUPS OR VIEW_ROLES
+ */
+const checkPeoplePickerAccess = async (req, res, next) => {
+ try {
+ const user = req.user;
+ if (!user || !user.role) {
+ return res.status(401).json({
+ error: 'Unauthorized',
+ message: 'Authentication required',
+ });
+ }
+
+ const role = await getRoleByName(user.role);
+ if (!role || !role.permissions) {
+ return res.status(403).json({
+ error: 'Forbidden',
+ message: 'No permissions configured for user role',
+ });
+ }
+
+ const { type } = req.query;
+ const peoplePickerPerms = role.permissions[PermissionTypes.PEOPLE_PICKER] || {};
+ const canViewUsers = peoplePickerPerms[Permissions.VIEW_USERS] === true;
+ const canViewGroups = peoplePickerPerms[Permissions.VIEW_GROUPS] === true;
+ const canViewRoles = peoplePickerPerms[Permissions.VIEW_ROLES] === true;
+
+ const permissionChecks = {
+ [PrincipalType.USER]: {
+ hasPermission: canViewUsers,
+ message: 'Insufficient permissions to search for users',
+ },
+ [PrincipalType.GROUP]: {
+ hasPermission: canViewGroups,
+ message: 'Insufficient permissions to search for groups',
+ },
+ [PrincipalType.ROLE]: {
+ hasPermission: canViewRoles,
+ message: 'Insufficient permissions to search for roles',
+ },
+ };
+
+ const check = permissionChecks[type];
+ if (check && !check.hasPermission) {
+ return res.status(403).json({
+ error: 'Forbidden',
+ message: check.message,
+ });
+ }
+
+ if (!type && !canViewUsers && !canViewGroups && !canViewRoles) {
+ return res.status(403).json({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to search for users, groups, or roles',
+ });
+ }
+
+ next();
+ } catch (error) {
+ logger.error(
+ `[checkPeoplePickerAccess][${req.user?.id}] checkPeoplePickerAccess error for req.query.type = ${req.query.type}`,
+ error,
+ );
+ return res.status(500).json({
+ error: 'Internal Server Error',
+ message: 'Failed to check permissions',
+ });
+ }
+};
+
+module.exports = {
+ checkPeoplePickerAccess,
+};
diff --git a/api/server/middleware/checkPeoplePickerAccess.spec.js b/api/server/middleware/checkPeoplePickerAccess.spec.js
new file mode 100644
index 0000000000..52bf0e6724
--- /dev/null
+++ b/api/server/middleware/checkPeoplePickerAccess.spec.js
@@ -0,0 +1,251 @@
+const { logger } = require('@librechat/data-schemas');
+const { PrincipalType, PermissionTypes, Permissions } = require('librechat-data-provider');
+const { checkPeoplePickerAccess } = require('./checkPeoplePickerAccess');
+const { getRoleByName } = require('~/models/Role');
+
+jest.mock('~/models/Role');
+jest.mock('@librechat/data-schemas', () => ({
+ ...jest.requireActual('@librechat/data-schemas'),
+ logger: {
+ error: jest.fn(),
+ },
+}));
+
+describe('checkPeoplePickerAccess', () => {
+ let req, res, next;
+
+ beforeEach(() => {
+ req = {
+ user: { id: 'user123', role: 'USER' },
+ query: {},
+ };
+ res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+ next = jest.fn();
+ jest.clearAllMocks();
+ });
+
+ it('should return 401 if user is not authenticated', async () => {
+ req.user = null;
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Unauthorized',
+ message: 'Authentication required',
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should return 403 if role has no permissions', async () => {
+ getRoleByName.mockResolvedValue(null);
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'No permissions configured for user role',
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should allow access when searching for users with VIEW_USERS permission', async () => {
+ req.query.type = PrincipalType.USER;
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: true,
+ [Permissions.VIEW_GROUPS]: false,
+ [Permissions.VIEW_ROLES]: false,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ it('should deny access when searching for users without VIEW_USERS permission', async () => {
+ req.query.type = PrincipalType.USER;
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: false,
+ [Permissions.VIEW_GROUPS]: true,
+ [Permissions.VIEW_ROLES]: true,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to search for users',
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should allow access when searching for groups with VIEW_GROUPS permission', async () => {
+ req.query.type = PrincipalType.GROUP;
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: false,
+ [Permissions.VIEW_GROUPS]: true,
+ [Permissions.VIEW_ROLES]: false,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ it('should deny access when searching for groups without VIEW_GROUPS permission', async () => {
+ req.query.type = PrincipalType.GROUP;
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: true,
+ [Permissions.VIEW_GROUPS]: false,
+ [Permissions.VIEW_ROLES]: true,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to search for groups',
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should allow access when searching for roles with VIEW_ROLES permission', async () => {
+ req.query.type = PrincipalType.ROLE;
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: false,
+ [Permissions.VIEW_GROUPS]: false,
+ [Permissions.VIEW_ROLES]: true,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ it('should deny access when searching for roles without VIEW_ROLES permission', async () => {
+ req.query.type = PrincipalType.ROLE;
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: true,
+ [Permissions.VIEW_GROUPS]: true,
+ [Permissions.VIEW_ROLES]: false,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to search for roles',
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should allow mixed search when user has at least one permission', async () => {
+ // No type specified = mixed search
+ req.query.type = undefined;
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: false,
+ [Permissions.VIEW_GROUPS]: false,
+ [Permissions.VIEW_ROLES]: true,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ it('should deny mixed search when user has no permissions', async () => {
+ // No type specified = mixed search
+ req.query.type = undefined;
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.PEOPLE_PICKER]: {
+ [Permissions.VIEW_USERS]: false,
+ [Permissions.VIEW_GROUPS]: false,
+ [Permissions.VIEW_ROLES]: false,
+ },
+ },
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to search for users, groups, or roles',
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should handle errors gracefully', async () => {
+ const error = new Error('Database error');
+ getRoleByName.mockRejectedValue(error);
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(logger.error).toHaveBeenCalledWith(
+ '[checkPeoplePickerAccess][user123] checkPeoplePickerAccess error for req.query.type = undefined',
+ error,
+ );
+ expect(res.status).toHaveBeenCalledWith(500);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Internal Server Error',
+ message: 'Failed to check permissions',
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should handle missing permissions object gracefully', async () => {
+ req.query.type = PrincipalType.USER;
+ getRoleByName.mockResolvedValue({
+ permissions: {}, // No PEOPLE_PICKER permissions
+ });
+
+ await checkPeoplePickerAccess(req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: 'Insufficient permissions to search for users',
+ });
+ expect(next).not.toHaveBeenCalled();
+ });
+});
diff --git a/api/server/middleware/concurrentLimiter.js b/api/server/middleware/concurrentLimiter.js
new file mode 100644
index 0000000000..96885e2fd4
--- /dev/null
+++ b/api/server/middleware/concurrentLimiter.js
@@ -0,0 +1,76 @@
+const { isEnabled } = require('@librechat/api');
+const { Time, CacheKeys, ViolationTypes } = require('librechat-data-provider');
+const clearPendingReq = require('~/cache/clearPendingReq');
+const { logViolation, getLogStores } = require('~/cache');
+const denyRequest = require('./denyRequest');
+
+const {
+ USE_REDIS,
+ CONCURRENT_MESSAGE_MAX = 1,
+ CONCURRENT_VIOLATION_SCORE: score,
+} = process.env ?? {};
+
+/**
+ * Middleware to limit concurrent requests for a user.
+ *
+ * This middleware checks if a user has exceeded a specified concurrent request limit.
+ * If the user exceeds the limit, an error is returned. If the user is within the limit,
+ * their request count is incremented. After the request is processed, the count is decremented.
+ * If the `cache` store is not available, the middleware will skip its logic.
+ *
+ * @function
+ * @param {Object} req - Express request object containing user information.
+ * @param {Object} res - Express response object.
+ * @param {import('express').NextFunction} next - Next middleware function.
+ * @throws {Error} Throws an error if the user exceeds the concurrent request limit.
+ */
+const concurrentLimiter = async (req, res, next) => {
+ const namespace = CacheKeys.PENDING_REQ;
+ const cache = getLogStores(namespace);
+ if (!cache) {
+ return next();
+ }
+
+ if (Object.keys(req?.body ?? {}).length === 1 && req?.body?.abortKey) {
+ return next();
+ }
+
+ const userId = req.user?.id ?? req.user?._id ?? '';
+ const limit = Math.max(CONCURRENT_MESSAGE_MAX, 1);
+ const type = ViolationTypes.CONCURRENT;
+
+ const key = `${isEnabled(USE_REDIS) ? namespace : ''}:${userId}`;
+ const pendingRequests = +((await cache.get(key)) ?? 0);
+
+ if (pendingRequests >= limit) {
+ const errorMessage = {
+ type,
+ limit,
+ pendingRequests,
+ };
+
+ await logViolation(req, res, type, errorMessage, score);
+ return await denyRequest(req, res, errorMessage);
+ } else {
+ await cache.set(key, pendingRequests + 1, Time.ONE_MINUTE);
+ }
+
+ // Ensure the requests are removed from the store once the request is done
+ let cleared = false;
+ const cleanUp = async () => {
+ if (cleared) {
+ return;
+ }
+ cleared = true;
+ await clearPendingReq({ userId, cache });
+ };
+
+ if (pendingRequests < limit) {
+ res.on('finish', cleanUp);
+ res.on('close', cleanUp);
+ }
+
+ next();
+};
+
+module.exports = concurrentLimiter;
diff --git a/api/server/middleware/config/app.js b/api/server/middleware/config/app.js
new file mode 100644
index 0000000000..bca3c8f71d
--- /dev/null
+++ b/api/server/middleware/config/app.js
@@ -0,0 +1,27 @@
+const { logger } = require('@librechat/data-schemas');
+const { getAppConfig } = require('~/server/services/Config');
+
+const configMiddleware = async (req, res, next) => {
+ try {
+ const userRole = req.user?.role;
+ req.config = await getAppConfig({ role: userRole });
+
+ next();
+ } catch (error) {
+ logger.error('Config middleware error:', {
+ error: error.message,
+ userRole: req.user?.role,
+ path: req.path,
+ });
+
+ try {
+ req.config = await getAppConfig();
+ next();
+ } catch (fallbackError) {
+ logger.error('Fallback config middleware error:', fallbackError);
+ next(fallbackError);
+ }
+ }
+};
+
+module.exports = configMiddleware;
diff --git a/api/server/middleware/denyRequest.js b/api/server/middleware/denyRequest.js
new file mode 100644
index 0000000000..20360519cf
--- /dev/null
+++ b/api/server/middleware/denyRequest.js
@@ -0,0 +1,63 @@
+const crypto = require('crypto');
+const { sendEvent } = require('@librechat/api');
+const { getResponseSender, Constants } = require('librechat-data-provider');
+const { sendError } = require('~/server/middleware/error');
+const { saveMessage } = require('~/models');
+
+/**
+ * Denies a request by sending an error message and optionally saves the user's message.
+ *
+ * @async
+ * @function
+ * @param {Object} req - Express request object.
+ * @param {Object} req.body - The body of the request.
+ * @param {string} [req.body.messageId] - The ID of the message.
+ * @param {string} [req.body.conversationId] - The ID of the conversation.
+ * @param {string} [req.body.parentMessageId] - The ID of the parent message.
+ * @param {string} req.body.text - The text of the message.
+ * @param {Object} res - Express response object.
+ * @param {string} errorMessage - The error message to be sent.
+ * @returns {Promise} A promise that resolves with the error response.
+ * @throws {Error} Throws an error if there's an issue saving the message or sending the error.
+ */
+const denyRequest = async (req, res, errorMessage) => {
+ let responseText = errorMessage;
+ if (typeof errorMessage === 'object') {
+ responseText = JSON.stringify(errorMessage);
+ }
+
+ const { messageId, conversationId: _convoId, parentMessageId, text } = req.body;
+ const conversationId = _convoId ?? crypto.randomUUID();
+
+ const userMessage = {
+ sender: 'User',
+ messageId: messageId ?? crypto.randomUUID(),
+ parentMessageId,
+ conversationId,
+ isCreatedByUser: true,
+ text,
+ };
+ sendEvent(res, { message: userMessage, created: true });
+
+ const shouldSaveMessage = _convoId && parentMessageId && parentMessageId !== Constants.NO_PARENT;
+
+ if (shouldSaveMessage) {
+ await saveMessage(
+ req,
+ { ...userMessage, user: req.user.id },
+ { context: `api/server/middleware/denyRequest.js - ${responseText}` },
+ );
+ }
+
+ return await sendError(req, res, {
+ sender: getResponseSender(req.body),
+ messageId: crypto.randomUUID(),
+ conversationId,
+ parentMessageId: userMessage.messageId,
+ text: responseText,
+ shouldSaveMessage,
+ user: req.user.id,
+ });
+};
+
+module.exports = denyRequest;
diff --git a/api/server/middleware/error.js b/api/server/middleware/error.js
new file mode 100644
index 0000000000..fef7e60ef7
--- /dev/null
+++ b/api/server/middleware/error.js
@@ -0,0 +1,107 @@
+const crypto = require('crypto');
+const { logger } = require('@librechat/data-schemas');
+const { parseConvo } = require('librechat-data-provider');
+const { sendEvent, handleError, sanitizeMessageForTransmit } = require('@librechat/api');
+const { saveMessage, getMessages } = require('~/models/Message');
+const { getConvo } = require('~/models/Conversation');
+
+/**
+ * Processes an error with provided options, saves the error message and sends a corresponding SSE response
+ * @async
+ * @param {object} req - The request.
+ * @param {object} res - The response.
+ * @param {object} options - The options for handling the error containing message properties.
+ * @param {object} options.user - The user ID.
+ * @param {string} options.sender - The sender of the message.
+ * @param {string} options.conversationId - The conversation ID.
+ * @param {string} options.messageId - The message ID.
+ * @param {string} options.parentMessageId - The parent message ID.
+ * @param {string} options.text - The error message.
+ * @param {boolean} options.shouldSaveMessage - [Optional] Whether the message should be saved. Default is true.
+ * @param {function} callback - [Optional] The callback function to be executed.
+ */
+const sendError = async (req, res, options, callback) => {
+ const {
+ user,
+ sender,
+ conversationId,
+ messageId,
+ parentMessageId,
+ text,
+ shouldSaveMessage,
+ ...rest
+ } = options;
+ const errorMessage = {
+ sender,
+ messageId: messageId ?? crypto.randomUUID(),
+ conversationId,
+ parentMessageId,
+ unfinished: false,
+ error: true,
+ final: true,
+ text,
+ isCreatedByUser: false,
+ ...rest,
+ };
+ if (callback && typeof callback === 'function') {
+ await callback();
+ }
+
+ if (shouldSaveMessage) {
+ await saveMessage(
+ req,
+ { ...errorMessage, user },
+ {
+ context: 'api/server/utils/streamResponse.js - sendError',
+ },
+ );
+ }
+
+ if (!errorMessage.error) {
+ const requestMessage = { messageId: parentMessageId, conversationId };
+ let query = [],
+ convo = {};
+ try {
+ query = await getMessages(requestMessage);
+ convo = await getConvo(user, conversationId);
+ } catch (err) {
+ logger.error('[sendError] Error retrieving conversation data:', err);
+ convo = parseConvo(errorMessage);
+ }
+
+ return sendEvent(res, {
+ final: true,
+ requestMessage: sanitizeMessageForTransmit(query?.[0] ?? requestMessage),
+ responseMessage: errorMessage,
+ conversation: convo,
+ });
+ }
+
+ handleError(res, errorMessage);
+};
+
+/**
+ * Sends the response based on whether headers have been sent or not.
+ * @param {ServerRequest} req - The server response.
+ * @param {Express.Response} res - The server response.
+ * @param {Object} data - The data to be sent.
+ * @param {string} [errorMessage] - The error message, if any.
+ */
+const sendResponse = (req, res, data, errorMessage) => {
+ if (!res.headersSent) {
+ if (errorMessage) {
+ return res.status(500).json({ error: errorMessage });
+ }
+ return res.json(data);
+ }
+
+ if (errorMessage) {
+ return sendError(req, res, { ...data, text: errorMessage });
+ }
+ return sendEvent(res, data);
+};
+
+module.exports = {
+ sendError,
+ sendResponse,
+};
diff --git a/api/server/middleware/index.js b/api/server/middleware/index.js
new file mode 100644
index 0000000000..2aad5a47e7
--- /dev/null
+++ b/api/server/middleware/index.js
@@ -0,0 +1,51 @@
+const validatePasswordReset = require('./validatePasswordReset');
+const validateRegistration = require('./validateRegistration');
+const buildEndpointOption = require('./buildEndpointOption');
+const validateMessageReq = require('./validateMessageReq');
+const checkDomainAllowed = require('./checkDomainAllowed');
+const concurrentLimiter = require('./concurrentLimiter');
+const requireLocalAuth = require('./requireLocalAuth');
+const canDeleteAccount = require('./canDeleteAccount');
+const accessResources = require('./accessResources');
+const requireLdapAuth = require('./requireLdapAuth');
+const abortMiddleware = require('./abortMiddleware');
+const checkInviteUser = require('./checkInviteUser');
+const requireJwtAuth = require('./requireJwtAuth');
+const configMiddleware = require('./config/app');
+const validateModel = require('./validateModel');
+const moderateText = require('./moderateText');
+const logHeaders = require('./logHeaders');
+const setHeaders = require('./setHeaders');
+const validate = require('./validate');
+const limiters = require('./limiters');
+const uaParser = require('./uaParser');
+const checkBan = require('./checkBan');
+const noIndex = require('./noIndex');
+const roles = require('./roles');
+
+module.exports = {
+ ...abortMiddleware,
+ ...validate,
+ ...limiters,
+ ...roles,
+ ...accessResources,
+ noIndex,
+ checkBan,
+ uaParser,
+ setHeaders,
+ logHeaders,
+ moderateText,
+ validateModel,
+ requireJwtAuth,
+ checkInviteUser,
+ requireLdapAuth,
+ requireLocalAuth,
+ canDeleteAccount,
+ configMiddleware,
+ concurrentLimiter,
+ checkDomainAllowed,
+ validateMessageReq,
+ buildEndpointOption,
+ validateRegistration,
+ validatePasswordReset,
+};
diff --git a/api/server/middleware/limiters/forkLimiters.js b/api/server/middleware/limiters/forkLimiters.js
new file mode 100644
index 0000000000..e0aa65700c
--- /dev/null
+++ b/api/server/middleware/limiters/forkLimiters.js
@@ -0,0 +1,79 @@
+const rateLimit = require('express-rate-limit');
+const { limiterCache } = require('@librechat/api');
+const { ViolationTypes } = require('librechat-data-provider');
+const logViolation = require('~/cache/logViolation');
+
+const getEnvironmentVariables = () => {
+ const FORK_IP_MAX = parseInt(process.env.FORK_IP_MAX) || 30;
+ const FORK_IP_WINDOW = parseInt(process.env.FORK_IP_WINDOW) || 1;
+ const FORK_USER_MAX = parseInt(process.env.FORK_USER_MAX) || 7;
+ const FORK_USER_WINDOW = parseInt(process.env.FORK_USER_WINDOW) || 1;
+ const FORK_VIOLATION_SCORE = process.env.FORK_VIOLATION_SCORE;
+
+ const forkIpWindowMs = FORK_IP_WINDOW * 60 * 1000;
+ const forkIpMax = FORK_IP_MAX;
+ const forkIpWindowInMinutes = forkIpWindowMs / 60000;
+
+ const forkUserWindowMs = FORK_USER_WINDOW * 60 * 1000;
+ const forkUserMax = FORK_USER_MAX;
+ const forkUserWindowInMinutes = forkUserWindowMs / 60000;
+
+ return {
+ forkIpWindowMs,
+ forkIpMax,
+ forkIpWindowInMinutes,
+ forkUserWindowMs,
+ forkUserMax,
+ forkUserWindowInMinutes,
+ forkViolationScore: FORK_VIOLATION_SCORE,
+ };
+};
+
+const createForkHandler = (ip = true) => {
+ const {
+ forkIpMax,
+ forkUserMax,
+ forkViolationScore,
+ forkIpWindowInMinutes,
+ forkUserWindowInMinutes,
+ } = getEnvironmentVariables();
+
+ return async (req, res) => {
+ const type = ViolationTypes.FILE_UPLOAD_LIMIT;
+ const errorMessage = {
+ type,
+ max: ip ? forkIpMax : forkUserMax,
+ limiter: ip ? 'ip' : 'user',
+ windowInMinutes: ip ? forkIpWindowInMinutes : forkUserWindowInMinutes,
+ };
+
+ await logViolation(req, res, type, errorMessage, forkViolationScore);
+ res.status(429).json({ message: 'Too many conversation fork requests. Try again later' });
+ };
+};
+
+const createForkLimiters = () => {
+ const { forkIpWindowMs, forkIpMax, forkUserWindowMs, forkUserMax } = getEnvironmentVariables();
+
+ const ipLimiterOptions = {
+ windowMs: forkIpWindowMs,
+ max: forkIpMax,
+ handler: createForkHandler(),
+ store: limiterCache('fork_ip_limiter'),
+ };
+ const userLimiterOptions = {
+ windowMs: forkUserWindowMs,
+ max: forkUserMax,
+ handler: createForkHandler(false),
+ keyGenerator: function (req) {
+ return req.user?.id;
+ },
+ store: limiterCache('fork_user_limiter'),
+ };
+
+ const forkIpLimiter = rateLimit(ipLimiterOptions);
+ const forkUserLimiter = rateLimit(userLimiterOptions);
+ return { forkIpLimiter, forkUserLimiter };
+};
+
+module.exports = { createForkLimiters };
diff --git a/api/server/middleware/limiters/importLimiters.js b/api/server/middleware/limiters/importLimiters.js
new file mode 100644
index 0000000000..f383e99563
--- /dev/null
+++ b/api/server/middleware/limiters/importLimiters.js
@@ -0,0 +1,80 @@
+const rateLimit = require('express-rate-limit');
+const { limiterCache } = require('@librechat/api');
+const { ViolationTypes } = require('librechat-data-provider');
+const logViolation = require('~/cache/logViolation');
+
+const getEnvironmentVariables = () => {
+ const IMPORT_IP_MAX = parseInt(process.env.IMPORT_IP_MAX) || 100;
+ const IMPORT_IP_WINDOW = parseInt(process.env.IMPORT_IP_WINDOW) || 15;
+ const IMPORT_USER_MAX = parseInt(process.env.IMPORT_USER_MAX) || 50;
+ const IMPORT_USER_WINDOW = parseInt(process.env.IMPORT_USER_WINDOW) || 15;
+ const IMPORT_VIOLATION_SCORE = process.env.IMPORT_VIOLATION_SCORE;
+
+ const importIpWindowMs = IMPORT_IP_WINDOW * 60 * 1000;
+ const importIpMax = IMPORT_IP_MAX;
+ const importIpWindowInMinutes = importIpWindowMs / 60000;
+
+ const importUserWindowMs = IMPORT_USER_WINDOW * 60 * 1000;
+ const importUserMax = IMPORT_USER_MAX;
+ const importUserWindowInMinutes = importUserWindowMs / 60000;
+
+ return {
+ importIpWindowMs,
+ importIpMax,
+ importIpWindowInMinutes,
+ importUserWindowMs,
+ importUserMax,
+ importUserWindowInMinutes,
+ importViolationScore: IMPORT_VIOLATION_SCORE,
+ };
+};
+
+const createImportHandler = (ip = true) => {
+ const {
+ importIpMax,
+ importUserMax,
+ importViolationScore,
+ importIpWindowInMinutes,
+ importUserWindowInMinutes,
+ } = getEnvironmentVariables();
+
+ return async (req, res) => {
+ const type = ViolationTypes.FILE_UPLOAD_LIMIT;
+ const errorMessage = {
+ type,
+ max: ip ? importIpMax : importUserMax,
+ limiter: ip ? 'ip' : 'user',
+ windowInMinutes: ip ? importIpWindowInMinutes : importUserWindowInMinutes,
+ };
+
+ await logViolation(req, res, type, errorMessage, importViolationScore);
+ res.status(429).json({ message: 'Too many conversation import requests. Try again later' });
+ };
+};
+
+const createImportLimiters = () => {
+ const { importIpWindowMs, importIpMax, importUserWindowMs, importUserMax } =
+ getEnvironmentVariables();
+
+ const ipLimiterOptions = {
+ windowMs: importIpWindowMs,
+ max: importIpMax,
+ handler: createImportHandler(),
+ store: limiterCache('import_ip_limiter'),
+ };
+ const userLimiterOptions = {
+ windowMs: importUserWindowMs,
+ max: importUserMax,
+ handler: createImportHandler(false),
+ keyGenerator: function (req) {
+ return req.user?.id; // Use the user ID or NULL if not available
+ },
+ store: limiterCache('import_user_limiter'),
+ };
+
+ const importIpLimiter = rateLimit(ipLimiterOptions);
+ const importUserLimiter = rateLimit(userLimiterOptions);
+ return { importIpLimiter, importUserLimiter };
+};
+
+module.exports = { createImportLimiters };
diff --git a/api/server/middleware/limiters/index.js b/api/server/middleware/limiters/index.js
new file mode 100644
index 0000000000..ab110443dc
--- /dev/null
+++ b/api/server/middleware/limiters/index.js
@@ -0,0 +1,26 @@
+const createTTSLimiters = require('./ttsLimiters');
+const createSTTLimiters = require('./sttLimiters');
+
+const loginLimiter = require('./loginLimiter');
+const importLimiters = require('./importLimiters');
+const uploadLimiters = require('./uploadLimiters');
+const forkLimiters = require('./forkLimiters');
+const registerLimiter = require('./registerLimiter');
+const toolCallLimiter = require('./toolCallLimiter');
+const messageLimiters = require('./messageLimiters');
+const verifyEmailLimiter = require('./verifyEmailLimiter');
+const resetPasswordLimiter = require('./resetPasswordLimiter');
+
+module.exports = {
+ ...uploadLimiters,
+ ...importLimiters,
+ ...messageLimiters,
+ ...forkLimiters,
+ loginLimiter,
+ registerLimiter,
+ toolCallLimiter,
+ createTTSLimiters,
+ createSTTLimiters,
+ verifyEmailLimiter,
+ resetPasswordLimiter,
+};
diff --git a/api/server/middleware/limiters/loginLimiter.js b/api/server/middleware/limiters/loginLimiter.js
new file mode 100644
index 0000000000..eef0c56bfc
--- /dev/null
+++ b/api/server/middleware/limiters/loginLimiter.js
@@ -0,0 +1,35 @@
+const rateLimit = require('express-rate-limit');
+const { limiterCache } = require('@librechat/api');
+const { ViolationTypes } = require('librechat-data-provider');
+const { removePorts } = require('~/server/utils');
+const { logViolation } = require('~/cache');
+
+const { LOGIN_WINDOW = 5, LOGIN_MAX = 7, LOGIN_VIOLATION_SCORE: score } = process.env;
+const windowMs = LOGIN_WINDOW * 60 * 1000;
+const max = LOGIN_MAX;
+const windowInMinutes = windowMs / 60000;
+const message = `Too many login attempts, please try again after ${windowInMinutes} minutes.`;
+
+const handler = async (req, res) => {
+ const type = ViolationTypes.LOGINS;
+ const errorMessage = {
+ type,
+ max,
+ windowInMinutes,
+ };
+
+ await logViolation(req, res, type, errorMessage, score);
+ return res.status(429).json({ message });
+};
+
+const limiterOptions = {
+ windowMs,
+ max,
+ handler,
+ keyGenerator: removePorts,
+ store: limiterCache('login_limiter'),
+};
+
+const loginLimiter = rateLimit(limiterOptions);
+
+module.exports = loginLimiter;
diff --git a/api/server/middleware/limiters/messageLimiters.js b/api/server/middleware/limiters/messageLimiters.js
new file mode 100644
index 0000000000..50f4dbc644
--- /dev/null
+++ b/api/server/middleware/limiters/messageLimiters.js
@@ -0,0 +1,79 @@
+const rateLimit = require('express-rate-limit');
+const { limiterCache } = require('@librechat/api');
+const { ViolationTypes } = require('librechat-data-provider');
+const denyRequest = require('~/server/middleware/denyRequest');
+const { logViolation } = require('~/cache');
+
+const {
+ MESSAGE_IP_MAX = 40,
+ MESSAGE_IP_WINDOW = 1,
+ MESSAGE_USER_MAX = 40,
+ MESSAGE_USER_WINDOW = 1,
+ MESSAGE_VIOLATION_SCORE: score,
+} = process.env;
+
+const ipWindowMs = MESSAGE_IP_WINDOW * 60 * 1000;
+const ipMax = MESSAGE_IP_MAX;
+const ipWindowInMinutes = ipWindowMs / 60000;
+
+const userWindowMs = MESSAGE_USER_WINDOW * 60 * 1000;
+const userMax = MESSAGE_USER_MAX;
+const userWindowInMinutes = userWindowMs / 60000;
+
+/**
+ * Creates either an IP/User message request rate limiter for excessive requests
+ * that properly logs and denies the violation.
+ *
+ * @param {boolean} [ip=true] - Whether to create an IP limiter or a user limiter.
+ * @returns {function} A rate limiter function.
+ *
+ */
+const createHandler = (ip = true) => {
+ return async (req, res) => {
+ const type = ViolationTypes.MESSAGE_LIMIT;
+ const errorMessage = {
+ type,
+ max: ip ? ipMax : userMax,
+ limiter: ip ? 'ip' : 'user',
+ windowInMinutes: ip ? ipWindowInMinutes : userWindowInMinutes,
+ };
+
+ await logViolation(req, res, type, errorMessage, score);
+ return await denyRequest(req, res, errorMessage);
+ };
+};
+
+/**
+ * Message request rate limiters
+ */
+const ipLimiterOptions = {
+ windowMs: ipWindowMs,
+ max: ipMax,
+ handler: createHandler(),
+ store: limiterCache('message_ip_limiter'),
+};
+
+const userLimiterOptions = {
+ windowMs: userWindowMs,
+ max: userMax,
+ handler: createHandler(false),
+ keyGenerator: function (req) {
+ return req.user?.id; // Use the user ID or NULL if not available
+ },
+ store: limiterCache('message_user_limiter'),
+};
+
+/**
+ * Message request rate limiter by IP
+ */
+const messageIpLimiter = rateLimit(ipLimiterOptions);
+
+/**
+ * Message request rate limiter by userId
+ */
+const messageUserLimiter = rateLimit(userLimiterOptions);
+
+module.exports = {
+ messageIpLimiter,
+ messageUserLimiter,
+};
diff --git a/api/server/middleware/limiters/registerLimiter.js b/api/server/middleware/limiters/registerLimiter.js
new file mode 100644
index 0000000000..eeebebdb42
--- /dev/null
+++ b/api/server/middleware/limiters/registerLimiter.js
@@ -0,0 +1,35 @@
+const rateLimit = require('express-rate-limit');
+const { limiterCache } = require('@librechat/api');
+const { ViolationTypes } = require('librechat-data-provider');
+const { removePorts } = require('~/server/utils');
+const { logViolation } = require('~/cache');
+
+const { REGISTER_WINDOW = 60, REGISTER_MAX = 5, REGISTRATION_VIOLATION_SCORE: score } = process.env;
+const windowMs = REGISTER_WINDOW * 60 * 1000;
+const max = REGISTER_MAX;
+const windowInMinutes = windowMs / 60000;
+const message = `Too many accounts created, please try again after ${windowInMinutes} minutes`;
+
+const handler = async (req, res) => {
+ const type = ViolationTypes.REGISTRATIONS;
+ const errorMessage = {
+ type,
+ max,
+ windowInMinutes,
+ };
+
+ await logViolation(req, res, type, errorMessage, score);
+ return res.status(429).json({ message });
+};
+
+const limiterOptions = {
+ windowMs,
+ max,
+ handler,
+ keyGenerator: removePorts,
+ store: limiterCache('register_limiter'),
+};
+
+const registerLimiter = rateLimit(limiterOptions);
+
+module.exports = registerLimiter;
diff --git a/api/server/middleware/limiters/resetPasswordLimiter.js b/api/server/middleware/limiters/resetPasswordLimiter.js
new file mode 100644
index 0000000000..d1dfe52a98
--- /dev/null
+++ b/api/server/middleware/limiters/resetPasswordLimiter.js
@@ -0,0 +1,39 @@
+const rateLimit = require('express-rate-limit');
+const { limiterCache } = require('@librechat/api');
+const { ViolationTypes } = require('librechat-data-provider');
+const { removePorts } = require('~/server/utils');
+const { logViolation } = require('~/cache');
+
+const {
+ RESET_PASSWORD_WINDOW = 2,
+ RESET_PASSWORD_MAX = 2,
+ RESET_PASSWORD_VIOLATION_SCORE: score,
+} = process.env;
+const windowMs = RESET_PASSWORD_WINDOW * 60 * 1000;
+const max = RESET_PASSWORD_MAX;
+const windowInMinutes = windowMs / 60000;
+const message = `Too many attempts, please try again after ${windowInMinutes} minute(s)`;
+
+const handler = async (req, res) => {
+ const type = ViolationTypes.RESET_PASSWORD_LIMIT;
+ const errorMessage = {
+ type,
+ max,
+ windowInMinutes,
+ };
+
+ await logViolation(req, res, type, errorMessage, score);
+ return res.status(429).json({ message });
+};
+
+const limiterOptions = {
+ windowMs,
+ max,
+ handler,
+ keyGenerator: removePorts,
+ store: limiterCache('reset_password_limiter'),
+};
+
+const resetPasswordLimiter = rateLimit(limiterOptions);
+
+module.exports = resetPasswordLimiter;
diff --git a/api/server/middleware/limiters/sttLimiters.js b/api/server/middleware/limiters/sttLimiters.js
new file mode 100644
index 0000000000..f2f47cf680
--- /dev/null
+++ b/api/server/middleware/limiters/sttLimiters.js
@@ -0,0 +1,76 @@
+const rateLimit = require('express-rate-limit');
+const { limiterCache } = require('@librechat/api');
+const { ViolationTypes } = require('librechat-data-provider');
+const logViolation = require('~/cache/logViolation');
+
+const getEnvironmentVariables = () => {
+ const STT_IP_MAX = parseInt(process.env.STT_IP_MAX) || 100;
+ const STT_IP_WINDOW = parseInt(process.env.STT_IP_WINDOW) || 1;
+ const STT_USER_MAX = parseInt(process.env.STT_USER_MAX) || 50;
+ const STT_USER_WINDOW = parseInt(process.env.STT_USER_WINDOW) || 1;
+ const STT_VIOLATION_SCORE = process.env.STT_VIOLATION_SCORE;
+
+ const sttIpWindowMs = STT_IP_WINDOW * 60 * 1000;
+ const sttIpMax = STT_IP_MAX;
+ const sttIpWindowInMinutes = sttIpWindowMs / 60000;
+
+ const sttUserWindowMs = STT_USER_WINDOW * 60 * 1000;
+ const sttUserMax = STT_USER_MAX;
+ const sttUserWindowInMinutes = sttUserWindowMs / 60000;
+
+ return {
+ sttIpWindowMs,
+ sttIpMax,
+ sttIpWindowInMinutes,
+ sttUserWindowMs,
+ sttUserMax,
+ sttUserWindowInMinutes,
+ sttViolationScore: STT_VIOLATION_SCORE,
+ };
+};
+
+const createSTTHandler = (ip = true) => {
+ const { sttIpMax, sttIpWindowInMinutes, sttUserMax, sttUserWindowInMinutes, sttViolationScore } =
+ getEnvironmentVariables();
+
+ return async (req, res) => {
+ const type = ViolationTypes.STT_LIMIT;
+ const errorMessage = {
+ type,
+ max: ip ? sttIpMax : sttUserMax,
+ limiter: ip ? 'ip' : 'user',
+ windowInMinutes: ip ? sttIpWindowInMinutes : sttUserWindowInMinutes,
+ };
+
+ await logViolation(req, res, type, errorMessage, sttViolationScore);
+ res.status(429).json({ message: 'Too many STT requests. Try again later' });
+ };
+};
+
+const createSTTLimiters = () => {
+ const { sttIpWindowMs, sttIpMax, sttUserWindowMs, sttUserMax } = getEnvironmentVariables();
+
+ const ipLimiterOptions = {
+ windowMs: sttIpWindowMs,
+ max: sttIpMax,
+ handler: createSTTHandler(),
+ store: limiterCache('stt_ip_limiter'),
+ };
+
+ const userLimiterOptions = {
+ windowMs: sttUserWindowMs,
+ max: sttUserMax,
+ handler: createSTTHandler(false),
+ keyGenerator: function (req) {
+ return req.user?.id; // Use the user ID or NULL if not available
+ },
+ store: limiterCache('stt_user_limiter'),
+ };
+
+ const sttIpLimiter = rateLimit(ipLimiterOptions);
+ const sttUserLimiter = rateLimit(userLimiterOptions);
+
+ return { sttIpLimiter, sttUserLimiter };
+};
+
+module.exports = createSTTLimiters;
diff --git a/api/server/middleware/limiters/toolCallLimiter.js b/api/server/middleware/limiters/toolCallLimiter.js
new file mode 100644
index 0000000000..56dbfff1cf
--- /dev/null
+++ b/api/server/middleware/limiters/toolCallLimiter.js
@@ -0,0 +1,33 @@
+const rateLimit = require('express-rate-limit');
+const { limiterCache } = require('@librechat/api');
+const { ViolationTypes } = require('librechat-data-provider');
+const logViolation = require('~/cache/logViolation');
+
+const { TOOL_CALL_VIOLATION_SCORE: score } = process.env;
+
+const handler = async (req, res) => {
+ const type = ViolationTypes.TOOL_CALL_LIMIT;
+ const errorMessage = {
+ type,
+ max: 1,
+ limiter: 'user',
+ windowInMinutes: 1,
+ };
+
+ await logViolation(req, res, type, errorMessage, score);
+ res.status(429).json({ message: 'Too many tool call requests. Try again later' });
+};
+
+const limiterOptions = {
+ windowMs: 1000,
+ max: 1,
+ handler,
+ keyGenerator: function (req) {
+ return req.user?.id;
+ },
+ store: limiterCache('tool_call_limiter'),
+};
+
+const toolCallLimiter = rateLimit(limiterOptions);
+
+module.exports = toolCallLimiter;
diff --git a/api/server/middleware/limiters/ttsLimiters.js b/api/server/middleware/limiters/ttsLimiters.js
new file mode 100644
index 0000000000..41dd9a6ba5
--- /dev/null
+++ b/api/server/middleware/limiters/ttsLimiters.js
@@ -0,0 +1,76 @@
+const rateLimit = require('express-rate-limit');
+const { limiterCache } = require('@librechat/api');
+const { ViolationTypes } = require('librechat-data-provider');
+const logViolation = require('~/cache/logViolation');
+
+const getEnvironmentVariables = () => {
+ const TTS_IP_MAX = parseInt(process.env.TTS_IP_MAX) || 100;
+ const TTS_IP_WINDOW = parseInt(process.env.TTS_IP_WINDOW) || 1;
+ const TTS_USER_MAX = parseInt(process.env.TTS_USER_MAX) || 50;
+ const TTS_USER_WINDOW = parseInt(process.env.TTS_USER_WINDOW) || 1;
+ const TTS_VIOLATION_SCORE = process.env.TTS_VIOLATION_SCORE;
+
+ const ttsIpWindowMs = TTS_IP_WINDOW * 60 * 1000;
+ const ttsIpMax = TTS_IP_MAX;
+ const ttsIpWindowInMinutes = ttsIpWindowMs / 60000;
+
+ const ttsUserWindowMs = TTS_USER_WINDOW * 60 * 1000;
+ const ttsUserMax = TTS_USER_MAX;
+ const ttsUserWindowInMinutes = ttsUserWindowMs / 60000;
+
+ return {
+ ttsIpWindowMs,
+ ttsIpMax,
+ ttsIpWindowInMinutes,
+ ttsUserWindowMs,
+ ttsUserMax,
+ ttsUserWindowInMinutes,
+ ttsViolationScore: TTS_VIOLATION_SCORE,
+ };
+};
+
+const createTTSHandler = (ip = true) => {
+ const { ttsIpMax, ttsIpWindowInMinutes, ttsUserMax, ttsUserWindowInMinutes, ttsViolationScore } =
+ getEnvironmentVariables();
+
+ return async (req, res) => {
+ const type = ViolationTypes.TTS_LIMIT;
+ const errorMessage = {
+ type,
+ max: ip ? ttsIpMax : ttsUserMax,
+ limiter: ip ? 'ip' : 'user',
+ windowInMinutes: ip ? ttsIpWindowInMinutes : ttsUserWindowInMinutes,
+ };
+
+ await logViolation(req, res, type, errorMessage, ttsViolationScore);
+ res.status(429).json({ message: 'Too many TTS requests. Try again later' });
+ };
+};
+
+const createTTSLimiters = () => {
+ const { ttsIpWindowMs, ttsIpMax, ttsUserWindowMs, ttsUserMax } = getEnvironmentVariables();
+
+ const ipLimiterOptions = {
+ windowMs: ttsIpWindowMs,
+ max: ttsIpMax,
+ handler: createTTSHandler(),
+ store: limiterCache('tts_ip_limiter'),
+ };
+
+ const userLimiterOptions = {
+ windowMs: ttsUserWindowMs,
+ max: ttsUserMax,
+ handler: createTTSHandler(false),
+ store: limiterCache('tts_user_limiter'),
+ keyGenerator: function (req) {
+ return req.user?.id; // Use the user ID or NULL if not available
+ },
+ };
+
+ const ttsIpLimiter = rateLimit(ipLimiterOptions);
+ const ttsUserLimiter = rateLimit(userLimiterOptions);
+
+ return { ttsIpLimiter, ttsUserLimiter };
+};
+
+module.exports = createTTSLimiters;
diff --git a/api/server/middleware/limiters/uploadLimiters.js b/api/server/middleware/limiters/uploadLimiters.js
new file mode 100644
index 0000000000..df6987877c
--- /dev/null
+++ b/api/server/middleware/limiters/uploadLimiters.js
@@ -0,0 +1,84 @@
+const rateLimit = require('express-rate-limit');
+const { limiterCache } = require('@librechat/api');
+const { ViolationTypes } = require('librechat-data-provider');
+const logViolation = require('~/cache/logViolation');
+
+const getEnvironmentVariables = () => {
+ const FILE_UPLOAD_IP_MAX = parseInt(process.env.FILE_UPLOAD_IP_MAX) || 100;
+ const FILE_UPLOAD_IP_WINDOW = parseInt(process.env.FILE_UPLOAD_IP_WINDOW) || 15;
+ const FILE_UPLOAD_USER_MAX = parseInt(process.env.FILE_UPLOAD_USER_MAX) || 50;
+ const FILE_UPLOAD_USER_WINDOW = parseInt(process.env.FILE_UPLOAD_USER_WINDOW) || 15;
+ const FILE_UPLOAD_VIOLATION_SCORE = process.env.FILE_UPLOAD_VIOLATION_SCORE;
+
+ const fileUploadIpWindowMs = FILE_UPLOAD_IP_WINDOW * 60 * 1000;
+ const fileUploadIpMax = FILE_UPLOAD_IP_MAX;
+ const fileUploadIpWindowInMinutes = fileUploadIpWindowMs / 60000;
+
+ const fileUploadUserWindowMs = FILE_UPLOAD_USER_WINDOW * 60 * 1000;
+ const fileUploadUserMax = FILE_UPLOAD_USER_MAX;
+ const fileUploadUserWindowInMinutes = fileUploadUserWindowMs / 60000;
+
+ return {
+ fileUploadIpWindowMs,
+ fileUploadIpMax,
+ fileUploadIpWindowInMinutes,
+ fileUploadUserWindowMs,
+ fileUploadUserMax,
+ fileUploadUserWindowInMinutes,
+ fileUploadViolationScore: FILE_UPLOAD_VIOLATION_SCORE,
+ };
+};
+
+const createFileUploadHandler = (ip = true) => {
+ const {
+ fileUploadIpMax,
+ fileUploadIpWindowInMinutes,
+ fileUploadUserMax,
+ fileUploadUserWindowInMinutes,
+ fileUploadViolationScore,
+ } = getEnvironmentVariables();
+
+ return async (req, res) => {
+ const type = ViolationTypes.FILE_UPLOAD_LIMIT;
+ const errorMessage = {
+ type,
+ max: ip ? fileUploadIpMax : fileUploadUserMax,
+ limiter: ip ? 'ip' : 'user',
+ windowInMinutes: ip ? fileUploadIpWindowInMinutes : fileUploadUserWindowInMinutes,
+ };
+
+ await logViolation(req, res, type, errorMessage, fileUploadViolationScore);
+ res.status(429).json({ message: 'Too many file upload requests. Try again later' });
+ };
+};
+
+const createFileLimiters = () => {
+ const { fileUploadIpWindowMs, fileUploadIpMax, fileUploadUserWindowMs, fileUploadUserMax } =
+ getEnvironmentVariables();
+
+ const ipLimiterOptions = {
+ windowMs: fileUploadIpWindowMs,
+ max: fileUploadIpMax,
+ handler: createFileUploadHandler(),
+ store: limiterCache('file_upload_ip_limiter'),
+ };
+
+ const userLimiterOptions = {
+ windowMs: fileUploadUserWindowMs,
+ max: fileUploadUserMax,
+ handler: createFileUploadHandler(false),
+ keyGenerator: function (req) {
+ return req.user?.id; // Use the user ID or NULL if not available
+ },
+ store: limiterCache('file_upload_user_limiter'),
+ };
+
+ const fileUploadIpLimiter = rateLimit(ipLimiterOptions);
+ const fileUploadUserLimiter = rateLimit(userLimiterOptions);
+
+ return { fileUploadIpLimiter, fileUploadUserLimiter };
+};
+
+module.exports = {
+ createFileLimiters,
+};
diff --git a/api/server/middleware/limiters/verifyEmailLimiter.js b/api/server/middleware/limiters/verifyEmailLimiter.js
new file mode 100644
index 0000000000..006c4df656
--- /dev/null
+++ b/api/server/middleware/limiters/verifyEmailLimiter.js
@@ -0,0 +1,39 @@
+const rateLimit = require('express-rate-limit');
+const { limiterCache } = require('@librechat/api');
+const { ViolationTypes } = require('librechat-data-provider');
+const { removePorts } = require('~/server/utils');
+const { logViolation } = require('~/cache');
+
+const {
+ VERIFY_EMAIL_WINDOW = 2,
+ VERIFY_EMAIL_MAX = 2,
+ VERIFY_EMAIL_VIOLATION_SCORE: score,
+} = process.env;
+const windowMs = VERIFY_EMAIL_WINDOW * 60 * 1000;
+const max = VERIFY_EMAIL_MAX;
+const windowInMinutes = windowMs / 60000;
+const message = `Too many attempts, please try again after ${windowInMinutes} minute(s)`;
+
+const handler = async (req, res) => {
+ const type = ViolationTypes.VERIFY_EMAIL_LIMIT;
+ const errorMessage = {
+ type,
+ max,
+ windowInMinutes,
+ };
+
+ await logViolation(req, res, type, errorMessage, score);
+ return res.status(429).json({ message });
+};
+
+const limiterOptions = {
+ windowMs,
+ max,
+ handler,
+ keyGenerator: removePorts,
+ store: limiterCache('verify_email_limiter'),
+};
+
+const verifyEmailLimiter = rateLimit(limiterOptions);
+
+module.exports = verifyEmailLimiter;
diff --git a/api/server/middleware/logHeaders.js b/api/server/middleware/logHeaders.js
new file mode 100644
index 0000000000..398542747a
--- /dev/null
+++ b/api/server/middleware/logHeaders.js
@@ -0,0 +1,32 @@
+const { logger } = require('@librechat/data-schemas');
+
+/**
+ * Middleware to log Forwarded Headers
+ * @function
+ * @param {ServerRequest} req - Express request object containing user information.
+ * @param {ServerResponse} res - Express response object.
+ * @param {import('express').NextFunction} next - Next middleware function.
+ * @throws {Error} Throws an error if the user exceeds the concurrent request limit.
+ */
+const logHeaders = (req, res, next) => {
+ try {
+ const forwardedHeaders = {};
+ if (req.headers['x-forwarded-for']) {
+ forwardedHeaders['x-forwarded-for'] = req.headers['x-forwarded-for'];
+ }
+ if (req.headers['x-forwarded-host']) {
+ forwardedHeaders['x-forwarded-host'] = req.headers['x-forwarded-host'];
+ }
+ if (req.headers['x-forwarded-proto']) {
+ forwardedHeaders['x-forwarded-proto'] = req.headers['x-forwarded-proto'];
+ }
+ if (Object.keys(forwardedHeaders).length > 0) {
+ logger.debug('X-Forwarded headers detected in OAuth request:', forwardedHeaders);
+ }
+ } catch (error) {
+ logger.error('Error logging X-Forwarded headers:', error);
+ }
+ next();
+};
+
+module.exports = logHeaders;
diff --git a/api/server/middleware/moderateText.js b/api/server/middleware/moderateText.js
new file mode 100644
index 0000000000..775afbafbf
--- /dev/null
+++ b/api/server/middleware/moderateText.js
@@ -0,0 +1,43 @@
+const axios = require('axios');
+const { isEnabled } = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+const { ErrorTypes } = require('librechat-data-provider');
+const denyRequest = require('./denyRequest');
+
+async function moderateText(req, res, next) {
+ 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,
+ },
+ {
+ 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);
+
+ 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();
+}
+
+module.exports = moderateText;
diff --git a/api/server/middleware/noIndex.js b/api/server/middleware/noIndex.js
new file mode 100644
index 0000000000..c4d7b55f2d
--- /dev/null
+++ b/api/server/middleware/noIndex.js
@@ -0,0 +1,11 @@
+const noIndex = (req, res, next) => {
+ const shouldNoIndex = process.env.NO_INDEX ? process.env.NO_INDEX === 'true' : true;
+
+ if (shouldNoIndex) {
+ res.setHeader('X-Robots-Tag', 'noindex');
+ }
+
+ next();
+};
+
+module.exports = noIndex;
diff --git a/api/server/middleware/optionalJwtAuth.js b/api/server/middleware/optionalJwtAuth.js
new file mode 100644
index 0000000000..2f59fdda4a
--- /dev/null
+++ b/api/server/middleware/optionalJwtAuth.js
@@ -0,0 +1,25 @@
+const cookies = require('cookie');
+const passport = require('passport');
+const { isEnabled } = require('@librechat/api');
+
+// This middleware does not require authentication,
+// but if the user is authenticated, it will set the user object.
+const optionalJwtAuth = (req, res, next) => {
+ const cookieHeader = req.headers.cookie;
+ const tokenProvider = cookieHeader ? cookies.parse(cookieHeader).token_provider : null;
+ const callback = (err, user) => {
+ if (err) {
+ return next(err);
+ }
+ if (user) {
+ req.user = user;
+ }
+ next();
+ };
+ if (tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
+ return passport.authenticate('openidJwt', { session: false }, callback)(req, res, next);
+ }
+ passport.authenticate('jwt', { session: false }, callback)(req, res, next);
+};
+
+module.exports = optionalJwtAuth;
diff --git a/api/server/middleware/requireJwtAuth.js b/api/server/middleware/requireJwtAuth.js
new file mode 100644
index 0000000000..ed83c4773e
--- /dev/null
+++ b/api/server/middleware/requireJwtAuth.js
@@ -0,0 +1,23 @@
+const cookies = require('cookie');
+const passport = require('passport');
+const { isEnabled } = require('@librechat/api');
+
+/**
+ * Custom Middleware to handle JWT authentication, with support for OpenID token reuse
+ * Switches between JWT and OpenID authentication based on cookies and environment settings
+ */
+const requireJwtAuth = (req, res, next) => {
+ // Check if token provider is specified in cookies
+ const cookieHeader = req.headers.cookie;
+ const tokenProvider = cookieHeader ? cookies.parse(cookieHeader).token_provider : null;
+
+ // Use OpenID authentication if token provider is OpenID and OPENID_REUSE_TOKENS is enabled
+ if (tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
+ return passport.authenticate('openidJwt', { session: false })(req, res, next);
+ }
+
+ // Default to standard JWT authentication
+ return passport.authenticate('jwt', { session: false })(req, res, next);
+};
+
+module.exports = requireJwtAuth;
diff --git a/api/server/middleware/requireLdapAuth.js b/api/server/middleware/requireLdapAuth.js
new file mode 100644
index 0000000000..fc9b158259
--- /dev/null
+++ b/api/server/middleware/requireLdapAuth.js
@@ -0,0 +1,22 @@
+const passport = require('passport');
+
+const requireLdapAuth = (req, res, next) => {
+ passport.authenticate('ldapauth', (err, user, info) => {
+ if (err) {
+ console.log({
+ title: '(requireLdapAuth) Error at passport.authenticate',
+ parameters: [{ name: 'error', value: err }],
+ });
+ return next(err);
+ }
+ if (!user) {
+ console.log({
+ title: '(requireLdapAuth) Error: No user',
+ });
+ return res.status(404).send(info);
+ }
+ req.user = user;
+ next();
+ })(req, res, next);
+};
+module.exports = requireLdapAuth;
diff --git a/api/server/middleware/requireLocalAuth.js b/api/server/middleware/requireLocalAuth.js
new file mode 100644
index 0000000000..e82388f298
--- /dev/null
+++ b/api/server/middleware/requireLocalAuth.js
@@ -0,0 +1,23 @@
+const passport = require('passport');
+const { logger } = require('@librechat/data-schemas');
+
+const requireLocalAuth = (req, res, next) => {
+ passport.authenticate('local', (err, user, info) => {
+ if (err) {
+ logger.error('[requireLocalAuth] Error at passport.authenticate:', err);
+ return next(err);
+ }
+ if (!user) {
+ logger.debug('[requireLocalAuth] Error: No user');
+ return res.status(404).send(info);
+ }
+ if (info && info.message) {
+ logger.debug('[requireLocalAuth] Error: ' + info.message);
+ return res.status(422).send({ message: info.message });
+ }
+ req.user = user;
+ next();
+ })(req, res, next);
+};
+
+module.exports = requireLocalAuth;
diff --git a/api/server/middleware/roles/access.spec.js b/api/server/middleware/roles/access.spec.js
new file mode 100644
index 0000000000..fe8d77a4f5
--- /dev/null
+++ b/api/server/middleware/roles/access.spec.js
@@ -0,0 +1,370 @@
+const mongoose = require('mongoose');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { checkAccess, generateCheckAccess } = require('@librechat/api');
+const { PermissionTypes, Permissions } = require('librechat-data-provider');
+const { getRoleByName } = require('~/models/Role');
+const { Role } = require('~/db/models');
+
+// Mock the logger from @librechat/data-schemas
+jest.mock('@librechat/data-schemas', () => ({
+ ...jest.requireActual('@librechat/data-schemas'),
+ logger: {
+ warn: jest.fn(),
+ error: jest.fn(),
+ info: jest.fn(),
+ debug: jest.fn(),
+ },
+}));
+
+// Mock the cache to use a simple in-memory implementation
+const mockCache = new Map();
+jest.mock('~/cache/getLogStores', () => {
+ return jest.fn(() => ({
+ get: jest.fn(async (key) => mockCache.get(key)),
+ set: jest.fn(async (key, value) => mockCache.set(key, value)),
+ clear: jest.fn(async () => mockCache.clear()),
+ }));
+});
+
+describe('Access Middleware', () => {
+ let mongoServer;
+ let req, res, next;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ await mongoose.connection.dropDatabase();
+ mockCache.clear(); // Clear the cache between tests
+
+ // Create test roles
+ await Role.create({
+ name: 'user',
+ permissions: {
+ [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
+ [PermissionTypes.PROMPTS]: {
+ [Permissions.SHARED_GLOBAL]: false,
+ [Permissions.USE]: true,
+ [Permissions.CREATE]: true,
+ },
+ [PermissionTypes.MEMORIES]: {
+ [Permissions.USE]: true,
+ [Permissions.CREATE]: true,
+ [Permissions.UPDATE]: true,
+ [Permissions.READ]: true,
+ [Permissions.OPT_OUT]: true,
+ },
+ [PermissionTypes.AGENTS]: {
+ [Permissions.USE]: true,
+ [Permissions.CREATE]: false,
+ [Permissions.SHARED_GLOBAL]: false,
+ },
+ [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
+ [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
+ [PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
+ [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
+ },
+ });
+
+ await Role.create({
+ name: 'admin',
+ permissions: {
+ [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
+ [PermissionTypes.PROMPTS]: {
+ [Permissions.SHARED_GLOBAL]: true,
+ [Permissions.USE]: true,
+ [Permissions.CREATE]: true,
+ },
+ [PermissionTypes.MEMORIES]: {
+ [Permissions.USE]: true,
+ [Permissions.CREATE]: true,
+ [Permissions.UPDATE]: true,
+ [Permissions.READ]: true,
+ [Permissions.OPT_OUT]: true,
+ },
+ [PermissionTypes.AGENTS]: {
+ [Permissions.USE]: true,
+ [Permissions.CREATE]: true,
+ [Permissions.SHARED_GLOBAL]: true,
+ },
+ [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
+ [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
+ [PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
+ [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
+ },
+ });
+
+ // Create limited role with no AGENTS permissions
+ await Role.create({
+ name: 'limited',
+ permissions: {
+ // Explicitly set AGENTS permissions to false
+ [PermissionTypes.AGENTS]: {
+ [Permissions.USE]: false,
+ [Permissions.CREATE]: false,
+ [Permissions.SHARED_GLOBAL]: false,
+ },
+ // Has permissions for other types
+ [PermissionTypes.PROMPTS]: {
+ [Permissions.USE]: true,
+ },
+ },
+ });
+
+ req = {
+ user: { id: 'user123', role: 'user' },
+ body: {},
+ originalUrl: '/test',
+ };
+ res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+ next = jest.fn();
+ jest.clearAllMocks();
+ });
+
+ describe('checkAccess', () => {
+ test('should return false if user is not provided', async () => {
+ const result = await checkAccess({
+ user: null,
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+ });
+ expect(result).toBe(false);
+ });
+
+ test('should return true if user has required permission', async () => {
+ const result = await checkAccess({
+ req: {},
+ user: { id: 'user123', role: 'user' },
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+ });
+ expect(result).toBe(true);
+ });
+
+ test('should return false if user lacks required permission', async () => {
+ const result = await checkAccess({
+ req: {},
+ user: { id: 'user123', role: 'user' },
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.CREATE],
+ getRoleByName,
+ });
+ expect(result).toBe(false);
+ });
+
+ test('should return false if user has only some of multiple permissions', async () => {
+ // User has USE but not CREATE, so should fail when checking for both
+ const result = await checkAccess({
+ req: {},
+ user: { id: 'user123', role: 'user' },
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.CREATE, Permissions.USE],
+ getRoleByName,
+ });
+ expect(result).toBe(false);
+ });
+
+ test('should return true if user has all of multiple permissions', async () => {
+ // Admin has both USE and CREATE
+ const result = await checkAccess({
+ req: {},
+ user: { id: 'admin123', role: 'admin' },
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.CREATE, Permissions.USE],
+ getRoleByName,
+ });
+ expect(result).toBe(true);
+ });
+
+ test('should check body properties when permission is not directly granted', async () => {
+ const req = { body: { id: 'agent123' } };
+ const result = await checkAccess({
+ req,
+ user: { id: 'user123', role: 'user' },
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.UPDATE],
+ bodyProps: {
+ [Permissions.UPDATE]: ['id'],
+ },
+ checkObject: req.body,
+ getRoleByName,
+ });
+ expect(result).toBe(true);
+ });
+
+ test('should return false if role is not found', async () => {
+ const result = await checkAccess({
+ req: {},
+ user: { id: 'user123', role: 'nonexistent' },
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+ });
+ expect(result).toBe(false);
+ });
+
+ test('should return false if role has no permissions for the requested type', async () => {
+ const result = await checkAccess({
+ req: {},
+ user: { id: 'user123', role: 'limited' },
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+ });
+ expect(result).toBe(false);
+ });
+
+ test('should handle admin role with all permissions', async () => {
+ const createResult = await checkAccess({
+ req: {},
+ user: { id: 'admin123', role: 'admin' },
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.CREATE],
+ getRoleByName,
+ });
+ expect(createResult).toBe(true);
+
+ const shareResult = await checkAccess({
+ req: {},
+ user: { id: 'admin123', role: 'admin' },
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.SHARED_GLOBAL],
+ getRoleByName,
+ });
+ expect(shareResult).toBe(true);
+ });
+ });
+
+ describe('generateCheckAccess', () => {
+ test('should call next() when user has required permission', async () => {
+ const middleware = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+ });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('should return 403 when user lacks permission', async () => {
+ const middleware = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.CREATE],
+ getRoleByName,
+ });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
+ });
+
+ test('should check body properties when configured', async () => {
+ req.body = { agentId: 'agent123', description: 'test' };
+
+ const bodyProps = {
+ [Permissions.CREATE]: ['agentId'],
+ };
+
+ const middleware = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.CREATE],
+ bodyProps,
+ getRoleByName,
+ });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('should handle database errors gracefully', async () => {
+ // Mock getRoleByName to throw an error
+ const mockGetRoleByName = jest
+ .fn()
+ .mockRejectedValue(new Error('Database connection failed'));
+
+ const middleware = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE],
+ getRoleByName: mockGetRoleByName,
+ });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(500);
+ expect(res.json).toHaveBeenCalledWith({
+ message: expect.stringContaining('Server error:'),
+ });
+ });
+
+ test('should work with multiple permission types', async () => {
+ req.user.role = 'admin';
+
+ const middleware = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE, Permissions.CREATE, Permissions.SHARED_GLOBAL],
+ getRoleByName,
+ });
+ await middleware(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should handle missing user gracefully', async () => {
+ req.user = null;
+
+ const middleware = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+ });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
+ });
+
+ test('should handle role with no AGENTS permissions', async () => {
+ await Role.create({
+ name: 'noaccess',
+ permissions: {
+ // Explicitly set AGENTS with all permissions false
+ [PermissionTypes.AGENTS]: {
+ [Permissions.USE]: false,
+ [Permissions.CREATE]: false,
+ [Permissions.SHARED_GLOBAL]: false,
+ },
+ },
+ });
+ req.user.role = 'noaccess';
+
+ const middleware = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+ });
+ await middleware(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
+ });
+ });
+});
diff --git a/api/server/middleware/roles/admin.js b/api/server/middleware/roles/admin.js
new file mode 100644
index 0000000000..3cb93fab53
--- /dev/null
+++ b/api/server/middleware/roles/admin.js
@@ -0,0 +1,14 @@
+const { SystemRoles } = require('librechat-data-provider');
+
+function checkAdmin(req, res, next) {
+ try {
+ if (req.user.role !== SystemRoles.ADMIN) {
+ return res.status(403).json({ message: 'Forbidden' });
+ }
+ next();
+ } catch (error) {
+ res.status(500).json({ message: 'Internal Server Error' });
+ }
+}
+
+module.exports = checkAdmin;
diff --git a/api/server/middleware/roles/index.js b/api/server/middleware/roles/index.js
new file mode 100644
index 0000000000..f01b884e5a
--- /dev/null
+++ b/api/server/middleware/roles/index.js
@@ -0,0 +1,5 @@
+const checkAdmin = require('./admin');
+
+module.exports = {
+ checkAdmin,
+};
diff --git a/api/server/middleware/setHeaders.js b/api/server/middleware/setHeaders.js
new file mode 100644
index 0000000000..c1b58e2a5a
--- /dev/null
+++ b/api/server/middleware/setHeaders.js
@@ -0,0 +1,12 @@
+function setHeaders(req, res, next) {
+ res.writeHead(200, {
+ Connection: 'keep-alive',
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache, no-transform',
+ 'Access-Control-Allow-Origin': '*',
+ 'X-Accel-Buffering': 'no',
+ });
+ next();
+}
+
+module.exports = setHeaders;
diff --git a/api/server/middleware/spec/validateImages.spec.js b/api/server/middleware/spec/validateImages.spec.js
new file mode 100644
index 0000000000..ebf5eafc8a
--- /dev/null
+++ b/api/server/middleware/spec/validateImages.spec.js
@@ -0,0 +1,474 @@
+const jwt = require('jsonwebtoken');
+const createValidateImageRequest = require('~/server/middleware/validateImageRequest');
+
+// Mock only isEnabled, keep getBasePath real so it reads process.env.DOMAIN_CLIENT
+jest.mock('@librechat/api', () => ({
+ ...jest.requireActual('@librechat/api'),
+ isEnabled: jest.fn(),
+}));
+
+const { isEnabled } = require('@librechat/api');
+
+describe('validateImageRequest middleware', () => {
+ let req, res, next, validateImageRequest;
+ const validObjectId = '65cfb246f7ecadb8b1e8036b';
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ req = {
+ headers: {},
+ originalUrl: '',
+ };
+ res = {
+ status: jest.fn().mockReturnThis(),
+ send: jest.fn(),
+ };
+ next = jest.fn();
+ process.env.JWT_REFRESH_SECRET = 'test-secret';
+ process.env.OPENID_REUSE_TOKENS = 'false';
+ delete process.env.DOMAIN_CLIENT; // Clear for tests without basePath
+
+ // Default: OpenID token reuse disabled
+ isEnabled.mockReturnValue(false);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('Factory function', () => {
+ test('should return a pass-through middleware if secureImageLinks is false', async () => {
+ const middleware = createValidateImageRequest(false);
+ await middleware(req, res, next);
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('should return validation middleware if secureImageLinks is true', async () => {
+ validateImageRequest = createValidateImageRequest(true);
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.send).toHaveBeenCalledWith('Unauthorized');
+ });
+ });
+
+ describe('Standard LibreChat token flow', () => {
+ beforeEach(() => {
+ validateImageRequest = createValidateImageRequest(true);
+ });
+
+ test('should return 401 if refresh token is not provided', async () => {
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.send).toHaveBeenCalledWith('Unauthorized');
+ });
+
+ test('should return 403 if refresh token is invalid', async () => {
+ req.headers.cookie = 'refreshToken=invalid-token';
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should return 403 if refresh token is expired', async () => {
+ const expiredToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) - 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${expiredToken}`;
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should call next() for valid image path', async () => {
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/example.jpg`;
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should return 403 for invalid image path', async () => {
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/example.jpg'; // Different ObjectId
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should allow agent avatar pattern for any valid ObjectId', async () => {
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/agent-avatar-12345.png';
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should prevent file traversal attempts', async () => {
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+
+ const traversalAttempts = [
+ `/images/${validObjectId}/../../../etc/passwd`,
+ `/images/${validObjectId}/..%2F..%2F..%2Fetc%2Fpasswd`,
+ `/images/${validObjectId}/image.jpg/../../../etc/passwd`,
+ `/images/${validObjectId}/%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd`,
+ ];
+
+ for (const attempt of traversalAttempts) {
+ req.originalUrl = attempt;
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ jest.clearAllMocks();
+ // Reset mocks for next iteration
+ res.status = jest.fn().mockReturnThis();
+ res.send = jest.fn();
+ }
+ });
+
+ test('should handle URL encoded characters in valid paths', async () => {
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/image%20with%20spaces.jpg`;
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+ });
+
+ describe('OpenID token flow', () => {
+ beforeEach(() => {
+ validateImageRequest = createValidateImageRequest(true);
+ // Enable OpenID token reuse
+ isEnabled.mockReturnValue(true);
+ process.env.OPENID_REUSE_TOKENS = 'true';
+ });
+
+ test('should return 403 if no OpenID user ID cookie when token_provider is openid', async () => {
+ req.headers.cookie = 'refreshToken=dummy-token; token_provider=openid';
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should validate JWT-signed user ID for OpenID flow', async () => {
+ const signedUserId = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${signedUserId}`;
+ req.originalUrl = `/images/${validObjectId}/example.jpg`;
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should return 403 for invalid JWT-signed user ID', async () => {
+ req.headers.cookie =
+ 'refreshToken=dummy-token; token_provider=openid; openid_user_id=invalid-jwt';
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should return 403 for expired JWT-signed user ID', async () => {
+ const expiredSignedUserId = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) - 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${expiredSignedUserId}`;
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should validate image path against JWT-signed user ID', async () => {
+ const signedUserId = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ const differentObjectId = '65cfb246f7ecadb8b1e8036c';
+ req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${signedUserId}`;
+ req.originalUrl = `/images/${differentObjectId}/example.jpg`;
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should allow agent avatars in OpenID flow', async () => {
+ const signedUserId = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${signedUserId}`;
+ req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/agent-avatar-12345.png';
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+ });
+
+ describe('Security edge cases', () => {
+ let validToken;
+
+ beforeEach(() => {
+ validateImageRequest = createValidateImageRequest(true);
+ validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ });
+
+ test('should handle very long image filenames', async () => {
+ const longFilename = 'a'.repeat(1000) + '.jpg';
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/${longFilename}`;
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should handle URLs with maximum practical length', async () => {
+ // Most browsers support URLs up to ~2000 characters
+ const longFilename = 'x'.repeat(1900) + '.jpg';
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/${longFilename}`;
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should accept URLs just under the 2048 limit', async () => {
+ // Create a URL exactly 2047 characters long
+ const baseLength = `/images/${validObjectId}/`.length + '.jpg'.length;
+ const filenameLength = 2047 - baseLength;
+ const filename = 'a'.repeat(filenameLength) + '.jpg';
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/${filename}`;
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should handle malformed URL encoding gracefully', async () => {
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/test%ZZinvalid.jpg`;
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should reject URLs with null bytes', async () => {
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/test\x00.jpg`;
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should handle URLs with repeated slashes', async () => {
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}//test.jpg`;
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should reject extremely long URLs as potential DoS', async () => {
+ // Create a URL longer than 2048 characters
+ const baseLength = `/images/${validObjectId}/`.length + '.jpg'.length;
+ const filenameLength = 2049 - baseLength; // Ensure total length exceeds 2048
+ const extremelyLongFilename = 'x'.repeat(filenameLength) + '.jpg';
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/${extremelyLongFilename}`;
+ // Verify our test URL is actually too long
+ expect(req.originalUrl.length).toBeGreaterThan(2048);
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+ });
+
+ describe('basePath functionality', () => {
+ let originalDomainClient;
+
+ beforeEach(() => {
+ originalDomainClient = process.env.DOMAIN_CLIENT;
+ });
+
+ afterEach(() => {
+ process.env.DOMAIN_CLIENT = originalDomainClient;
+ });
+
+ test('should validate image paths with base path', async () => {
+ process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
+
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should validate agent avatar paths with base path', async () => {
+ process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/librechat/images/${validObjectId}/agent-avatar.png`;
+
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should reject image paths without base path when DOMAIN_CLIENT is set', async () => {
+ process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/test.jpg`;
+
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should handle empty base path (root deployment)', async () => {
+ process.env.DOMAIN_CLIENT = 'http://localhost:3080/';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/test.jpg`;
+
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should handle missing DOMAIN_CLIENT', async () => {
+ delete process.env.DOMAIN_CLIENT;
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/test.jpg`;
+
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should handle nested subdirectories in base path', async () => {
+ process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/apps/librechat/images/${validObjectId}/test.jpg`;
+
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should prevent path traversal with base path', async () => {
+ process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/librechat/images/${validObjectId}/../../../etc/passwd`;
+
+ await validateImageRequest(req, res, next);
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.send).toHaveBeenCalledWith('Access Denied');
+ });
+
+ test('should handle URLs with query parameters and base path', async () => {
+ process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/librechat/images/${validObjectId}/test.jpg?version=1`;
+
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should handle URLs with fragments and base path', async () => {
+ process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/librechat/images/${validObjectId}/test.jpg#section`;
+
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should handle HTTPS URLs with base path', async () => {
+ process.env.DOMAIN_CLIENT = 'https://example.com/librechat';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
+
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should handle invalid DOMAIN_CLIENT gracefully', async () => {
+ process.env.DOMAIN_CLIENT = 'not-a-valid-url';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}`;
+ req.originalUrl = `/images/${validObjectId}/test.jpg`;
+
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ test('should handle OpenID flow with base path', async () => {
+ process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
+ process.env.OPENID_REUSE_TOKENS = 'true';
+ const validToken = jwt.sign(
+ { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
+ process.env.JWT_REFRESH_SECRET,
+ );
+ req.headers.cookie = `refreshToken=${validToken}; token_provider=openid; openid_user_id=${validToken}`;
+ req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
+
+ await validateImageRequest(req, res, next);
+ expect(next).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/api/server/middleware/uaParser.js b/api/server/middleware/uaParser.js
new file mode 100644
index 0000000000..d7d38f1023
--- /dev/null
+++ b/api/server/middleware/uaParser.js
@@ -0,0 +1,32 @@
+const uap = require('ua-parser-js');
+const { ViolationTypes } = require('librechat-data-provider');
+const { handleError } = require('@librechat/api');
+const { logViolation } = require('../../cache');
+
+/**
+ * Middleware to parse User-Agent header and check if it's from a recognized browser.
+ * If the User-Agent is not recognized as a browser, logs a violation and sends an error response.
+ *
+ * @function
+ * @async
+ * @param {Object} req - Express request object.
+ * @param {Object} res - Express response object.
+ * @param {Function} next - Express next middleware function.
+ * @returns {void} Sends an error response if the User-Agent is not recognized as a browser.
+ *
+ * @example
+ * app.use(uaParser);
+ */
+async function uaParser(req, res, next) {
+ const { NON_BROWSER_VIOLATION_SCORE: score = 20 } = process.env;
+ const ua = uap(req.headers['user-agent']);
+
+ if (!ua.browser.name) {
+ const type = ViolationTypes.NON_BROWSER;
+ await logViolation(req, res, type, { type }, score);
+ return handleError(res, { message: 'Illegal request' });
+ }
+ next();
+}
+
+module.exports = uaParser;
diff --git a/api/server/middleware/validate/convoAccess.js b/api/server/middleware/validate/convoAccess.js
new file mode 100644
index 0000000000..ffee70ae61
--- /dev/null
+++ b/api/server/middleware/validate/convoAccess.js
@@ -0,0 +1,73 @@
+const { isEnabled } = require('@librechat/api');
+const { Constants, ViolationTypes, Time } = require('librechat-data-provider');
+const { searchConversation } = require('~/models/Conversation');
+const denyRequest = require('~/server/middleware/denyRequest');
+const { logViolation, getLogStores } = require('~/cache');
+
+const { USE_REDIS, CONVO_ACCESS_VIOLATION_SCORE: score = 0 } = process.env ?? {};
+
+/**
+ * Middleware to validate user's authorization for a conversation.
+ *
+ * This middleware checks if a user has the right to access a specific conversation.
+ * If the user doesn't have access, an error is returned. If the conversation doesn't exist,
+ * a not found error is returned. If the access is valid, the middleware allows the request to proceed.
+ * If the `cache` store is not available, the middleware will skip its logic.
+ *
+ * @function
+ * @param {ServerRequest} req - Express request object containing user information.
+ * @param {Express.Response} res - Express response object.
+ * @param {function} next - Express next middleware function.
+ * @throws {Error} Throws an error if the user doesn't have access to the conversation.
+ */
+const validateConvoAccess = async (req, res, next) => {
+ const namespace = ViolationTypes.CONVO_ACCESS;
+ const cache = getLogStores(namespace);
+
+ const conversationId = req.body.conversationId;
+
+ if (!conversationId || conversationId === Constants.NEW_CONVO) {
+ return next();
+ }
+
+ const userId = req.user?.id ?? req.user?._id ?? '';
+ const type = ViolationTypes.CONVO_ACCESS;
+ const key = `${isEnabled(USE_REDIS) ? namespace : ''}:${userId}:${conversationId}`;
+
+ try {
+ if (cache) {
+ const cachedAccess = await cache.get(key);
+ if (cachedAccess === 'authorized') {
+ return next();
+ }
+ }
+
+ const conversation = await searchConversation(conversationId);
+
+ if (!conversation) {
+ return next();
+ }
+
+ if (conversation.user !== userId) {
+ const errorMessage = {
+ type,
+ error: 'User not authorized for this conversation',
+ };
+
+ if (cache) {
+ await logViolation(req, res, type, errorMessage, score);
+ }
+ return await denyRequest(req, res, errorMessage);
+ }
+
+ if (cache) {
+ await cache.set(key, 'authorized', Time.TEN_MINUTES);
+ }
+ next();
+ } catch (error) {
+ console.error('Error validating conversation access:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+};
+
+module.exports = validateConvoAccess;
diff --git a/api/server/middleware/validate/index.js b/api/server/middleware/validate/index.js
new file mode 100644
index 0000000000..ce476e747f
--- /dev/null
+++ b/api/server/middleware/validate/index.js
@@ -0,0 +1,4 @@
+const validateConvoAccess = require('./convoAccess');
+module.exports = {
+ validateConvoAccess,
+};
diff --git a/api/server/middleware/validateImageRequest.js b/api/server/middleware/validateImageRequest.js
new file mode 100644
index 0000000000..b74ed7225e
--- /dev/null
+++ b/api/server/middleware/validateImageRequest.js
@@ -0,0 +1,157 @@
+const cookies = require('cookie');
+const jwt = require('jsonwebtoken');
+const { logger } = require('@librechat/data-schemas');
+const { isEnabled, getBasePath } = require('@librechat/api');
+
+const OBJECT_ID_LENGTH = 24;
+const OBJECT_ID_PATTERN = /^[0-9a-f]{24}$/i;
+
+/**
+ * Validates if a string is a valid MongoDB ObjectId
+ * @param {string} id - String to validate
+ * @returns {boolean} - Whether string is a valid ObjectId format
+ */
+function isValidObjectId(id) {
+ if (typeof id !== 'string') {
+ return false;
+ }
+ if (id.length !== OBJECT_ID_LENGTH) {
+ return false;
+ }
+ return OBJECT_ID_PATTERN.test(id);
+}
+
+/**
+ * Validates a LibreChat refresh token
+ * @param {string} refreshToken - The refresh token to validate
+ * @returns {{valid: boolean, userId?: string, error?: string}} - Validation result
+ */
+function validateToken(refreshToken) {
+ try {
+ const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
+
+ if (!isValidObjectId(payload.id)) {
+ return { valid: false, error: 'Invalid User ID' };
+ }
+
+ const currentTimeInSeconds = Math.floor(Date.now() / 1000);
+ if (payload.exp < currentTimeInSeconds) {
+ return { valid: false, error: 'Refresh token expired' };
+ }
+
+ return { valid: true, userId: payload.id };
+ } catch (err) {
+ logger.warn('[validateToken]', err);
+ return { valid: false, error: 'Invalid token' };
+ }
+}
+
+/**
+ * Factory to create the `validateImageRequest` middleware with configured secureImageLinks
+ * @param {boolean} [secureImageLinks] - Whether secure image links are enabled
+ */
+function createValidateImageRequest(secureImageLinks) {
+ if (!secureImageLinks) {
+ return (_req, _res, next) => next();
+ }
+ /**
+ * Middleware to validate image request.
+ * Supports both LibreChat refresh tokens and OpenID JWT tokens.
+ * Must be set by `secureImageLinks` via custom config file.
+ */
+ return async function validateImageRequest(req, res, next) {
+ try {
+ const cookieHeader = req.headers.cookie;
+ if (!cookieHeader) {
+ logger.warn('[validateImageRequest] No cookies provided');
+ return res.status(401).send('Unauthorized');
+ }
+
+ const parsedCookies = cookies.parse(cookieHeader);
+ const refreshToken = parsedCookies.refreshToken;
+
+ if (!refreshToken) {
+ logger.warn('[validateImageRequest] Token not provided');
+ return res.status(401).send('Unauthorized');
+ }
+
+ const tokenProvider = parsedCookies.token_provider;
+ let userIdForPath;
+
+ if (tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
+ const openidUserId = parsedCookies.openid_user_id;
+ if (!openidUserId) {
+ logger.warn('[validateImageRequest] No OpenID user ID cookie found');
+ return res.status(403).send('Access Denied');
+ }
+
+ const validationResult = validateToken(openidUserId);
+ if (!validationResult.valid) {
+ logger.warn(`[validateImageRequest] ${validationResult.error}`);
+ return res.status(403).send('Access Denied');
+ }
+ userIdForPath = validationResult.userId;
+ } else {
+ const validationResult = validateToken(refreshToken);
+ if (!validationResult.valid) {
+ logger.warn(`[validateImageRequest] ${validationResult.error}`);
+ return res.status(403).send('Access Denied');
+ }
+ userIdForPath = validationResult.userId;
+ }
+
+ if (!userIdForPath) {
+ logger.warn('[validateImageRequest] No user ID available for path validation');
+ return res.status(403).send('Access Denied');
+ }
+
+ const MAX_URL_LENGTH = 2048;
+ if (req.originalUrl.length > MAX_URL_LENGTH) {
+ logger.warn('[validateImageRequest] URL too long');
+ return res.status(403).send('Access Denied');
+ }
+
+ if (req.originalUrl.includes('\x00')) {
+ logger.warn('[validateImageRequest] URL contains null byte');
+ return res.status(403).send('Access Denied');
+ }
+
+ let fullPath;
+ try {
+ fullPath = decodeURIComponent(req.originalUrl);
+ } catch {
+ logger.warn('[validateImageRequest] Invalid URL encoding');
+ return res.status(403).send('Access Denied');
+ }
+
+ const basePath = getBasePath();
+ const imagesPath = `${basePath}/images`;
+
+ const agentAvatarPattern = new RegExp(
+ `^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[a-f0-9]{24}/agent-[^/]*$`,
+ );
+ if (agentAvatarPattern.test(fullPath)) {
+ logger.debug('[validateImageRequest] Image request validated');
+ return next();
+ }
+
+ const escapedUserId = userIdForPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const pathPattern = new RegExp(
+ `^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/${escapedUserId}/[^/]+$`,
+ );
+
+ if (pathPattern.test(fullPath)) {
+ logger.debug('[validateImageRequest] Image request validated');
+ next();
+ } else {
+ logger.warn('[validateImageRequest] Invalid image path');
+ res.status(403).send('Access Denied');
+ }
+ } catch (error) {
+ logger.error('[validateImageRequest] Error:', error);
+ res.status(500).send('Internal Server Error');
+ }
+ };
+}
+
+module.exports = createValidateImageRequest;
diff --git a/api/server/middleware/validateMessageReq.js b/api/server/middleware/validateMessageReq.js
new file mode 100644
index 0000000000..430444a172
--- /dev/null
+++ b/api/server/middleware/validateMessageReq.js
@@ -0,0 +1,28 @@
+const { getConvo } = require('~/models');
+
+// Middleware to validate conversationId and user relationship
+const validateMessageReq = async (req, res, next) => {
+ let conversationId = req.params.conversationId || req.body.conversationId;
+
+ if (conversationId === 'new') {
+ return res.status(200).send([]);
+ }
+
+ if (!conversationId && req.body.message) {
+ conversationId = req.body.message.conversationId;
+ }
+
+ const conversation = await getConvo(req.user.id, conversationId);
+
+ if (!conversation) {
+ return res.status(404).json({ error: 'Conversation not found' });
+ }
+
+ if (conversation.user !== req.user.id) {
+ return res.status(403).json({ error: 'User not authorized for this conversation' });
+ }
+
+ next();
+};
+
+module.exports = validateMessageReq;
diff --git a/api/server/middleware/validateModel.js b/api/server/middleware/validateModel.js
new file mode 100644
index 0000000000..40f6e67bfb
--- /dev/null
+++ b/api/server/middleware/validateModel.js
@@ -0,0 +1,47 @@
+const { handleError } = require('@librechat/api');
+const { ViolationTypes } = require('librechat-data-provider');
+const { getModelsConfig } = require('~/server/controllers/ModelController');
+const { logViolation } = require('~/cache');
+/**
+ * Validates the model of the request.
+ *
+ * @async
+ * @param {ServerRequest} req - The Express request object.
+ * @param {Express.Response} res - The Express response object.
+ * @param {Function} next - The Express next function.
+ */
+const validateModel = async (req, res, next) => {
+ const { model, endpoint } = req.body;
+ if (!model) {
+ return handleError(res, { text: 'Model not provided' });
+ }
+
+ const modelsConfig = await getModelsConfig(req);
+
+ if (!modelsConfig) {
+ return handleError(res, { text: 'Models not loaded' });
+ }
+
+ const availableModels = modelsConfig[endpoint];
+ if (!availableModels) {
+ return handleError(res, { text: 'Endpoint models not loaded' });
+ }
+
+ let validModel = !!availableModels.find((availableModel) => availableModel === model);
+
+ if (validModel) {
+ return next();
+ }
+
+ const { ILLEGAL_MODEL_REQ_SCORE: score = 1 } = process.env ?? {};
+
+ const type = ViolationTypes.ILLEGAL_MODEL_REQUEST;
+ const errorMessage = {
+ type,
+ };
+
+ await logViolation(req, res, type, errorMessage, score);
+ return handleError(res, { text: 'Illegal model request' });
+};
+
+module.exports = validateModel;
diff --git a/api/server/middleware/validatePasswordReset.js b/api/server/middleware/validatePasswordReset.js
new file mode 100644
index 0000000000..b4acd66bb1
--- /dev/null
+++ b/api/server/middleware/validatePasswordReset.js
@@ -0,0 +1,13 @@
+const { isEnabled } = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+
+function validatePasswordReset(req, res, next) {
+ if (isEnabled(process.env.ALLOW_PASSWORD_RESET)) {
+ next();
+ } else {
+ logger.warn(`Password reset attempt while not allowed. IP: ${req.ip}`);
+ res.status(403).send('Password reset is not allowed.');
+ }
+}
+
+module.exports = validatePasswordReset;
diff --git a/api/server/middleware/validateRegistration.js b/api/server/middleware/validateRegistration.js
new file mode 100644
index 0000000000..79b5caca2d
--- /dev/null
+++ b/api/server/middleware/validateRegistration.js
@@ -0,0 +1,17 @@
+const { isEnabled } = require('@librechat/api');
+
+function validateRegistration(req, res, next) {
+ if (req.invite) {
+ return next();
+ }
+
+ if (isEnabled(process.env.ALLOW_REGISTRATION)) {
+ next();
+ } else {
+ return res.status(403).json({
+ message: 'Registration is not allowed.',
+ });
+ }
+}
+
+module.exports = validateRegistration;
diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js
new file mode 100644
index 0000000000..7d7d3ea13a
--- /dev/null
+++ b/api/server/routes/__tests__/config.spec.js
@@ -0,0 +1,105 @@
+jest.mock('~/cache/getLogStores');
+const request = require('supertest');
+const express = require('express');
+const configRoute = require('../config');
+// file deepcode ignore UseCsurfForExpress/test: test
+const app = express();
+app.disable('x-powered-by');
+app.use('/api/config', configRoute);
+
+afterEach(() => {
+ delete process.env.APP_TITLE;
+ delete process.env.GOOGLE_CLIENT_ID;
+ delete process.env.GOOGLE_CLIENT_SECRET;
+ delete process.env.FACEBOOK_CLIENT_ID;
+ delete process.env.FACEBOOK_CLIENT_SECRET;
+ delete process.env.OPENID_CLIENT_ID;
+ delete process.env.OPENID_CLIENT_SECRET;
+ delete process.env.OPENID_ISSUER;
+ delete process.env.OPENID_SESSION_SECRET;
+ delete process.env.OPENID_BUTTON_LABEL;
+ delete process.env.OPENID_AUTO_REDIRECT;
+ delete process.env.OPENID_AUTH_URL;
+ delete process.env.GITHUB_CLIENT_ID;
+ delete process.env.GITHUB_CLIENT_SECRET;
+ delete process.env.DISCORD_CLIENT_ID;
+ delete process.env.DISCORD_CLIENT_SECRET;
+ delete process.env.SAML_ENTRY_POINT;
+ delete process.env.SAML_ISSUER;
+ delete process.env.SAML_CERT;
+ delete process.env.SAML_SESSION_SECRET;
+ delete process.env.SAML_BUTTON_LABEL;
+ delete process.env.SAML_IMAGE_URL;
+ delete process.env.DOMAIN_SERVER;
+ delete process.env.ALLOW_REGISTRATION;
+ delete process.env.ALLOW_SOCIAL_LOGIN;
+ delete process.env.ALLOW_PASSWORD_RESET;
+ delete process.env.LDAP_URL;
+ delete process.env.LDAP_BIND_DN;
+ delete process.env.LDAP_BIND_CREDENTIALS;
+ delete process.env.LDAP_USER_SEARCH_BASE;
+ delete process.env.LDAP_SEARCH_FILTER;
+});
+
+//TODO: This works/passes locally but http request tests fail with 404 in CI. Need to figure out why.
+
+describe.skip('GET /', () => {
+ it('should return 200 and the correct body', async () => {
+ process.env.APP_TITLE = 'Test Title';
+ process.env.GOOGLE_CLIENT_ID = 'Test Google Client Id';
+ process.env.GOOGLE_CLIENT_SECRET = 'Test Google Client Secret';
+ process.env.FACEBOOK_CLIENT_ID = 'Test Facebook Client Id';
+ process.env.FACEBOOK_CLIENT_SECRET = 'Test Facebook Client Secret';
+ process.env.OPENID_CLIENT_ID = 'Test OpenID Id';
+ process.env.OPENID_CLIENT_SECRET = 'Test OpenID Secret';
+ process.env.OPENID_ISSUER = 'Test OpenID Issuer';
+ process.env.OPENID_SESSION_SECRET = 'Test Secret';
+ process.env.OPENID_BUTTON_LABEL = 'Test OpenID';
+ process.env.OPENID_AUTH_URL = 'http://test-server.com';
+ process.env.GITHUB_CLIENT_ID = 'Test Github client Id';
+ process.env.GITHUB_CLIENT_SECRET = 'Test Github client Secret';
+ process.env.DISCORD_CLIENT_ID = 'Test Discord client Id';
+ process.env.DISCORD_CLIENT_SECRET = 'Test Discord client Secret';
+ process.env.SAML_ENTRY_POINT = 'http://test-server.com';
+ process.env.SAML_ISSUER = 'Test SAML Issuer';
+ process.env.SAML_CERT = 'saml.pem';
+ process.env.SAML_SESSION_SECRET = 'Test Secret';
+ process.env.SAML_BUTTON_LABEL = 'Test SAML';
+ process.env.SAML_IMAGE_URL = 'http://test-server.com';
+ process.env.DOMAIN_SERVER = 'http://test-server.com';
+ process.env.ALLOW_REGISTRATION = 'true';
+ process.env.ALLOW_SOCIAL_LOGIN = 'true';
+ process.env.ALLOW_PASSWORD_RESET = 'true';
+ process.env.LDAP_URL = 'Test LDAP URL';
+ process.env.LDAP_BIND_DN = 'Test LDAP Bind DN';
+ process.env.LDAP_BIND_CREDENTIALS = 'Test LDAP Bind Credentials';
+ process.env.LDAP_USER_SEARCH_BASE = 'Test LDAP User Search Base';
+ process.env.LDAP_SEARCH_FILTER = 'Test LDAP Search Filter';
+
+ const response = await request(app).get('/');
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body).toEqual({
+ appTitle: 'Test Title',
+ socialLogins: ['google', 'facebook', 'openid', 'github', 'discord', 'saml'],
+ discordLoginEnabled: true,
+ facebookLoginEnabled: true,
+ githubLoginEnabled: true,
+ googleLoginEnabled: true,
+ openidLoginEnabled: true,
+ openidLabel: 'Test OpenID',
+ openidImageUrl: 'http://test-server.com',
+ samlLoginEnabled: true,
+ samlLabel: 'Test SAML',
+ samlImageUrl: 'http://test-server.com',
+ ldap: {
+ enabled: true,
+ },
+ serverDomain: 'http://test-server.com',
+ emailLoginEnabled: 'true',
+ registrationEnabled: 'true',
+ passwordResetEnabled: 'true',
+ socialLoginEnabled: 'true',
+ });
+ });
+});
diff --git a/api/server/routes/__tests__/convos.spec.js b/api/server/routes/__tests__/convos.spec.js
new file mode 100644
index 0000000000..e1f9469bef
--- /dev/null
+++ b/api/server/routes/__tests__/convos.spec.js
@@ -0,0 +1,502 @@
+const express = require('express');
+const request = require('supertest');
+
+jest.mock('@librechat/agents', () => ({
+ sleep: jest.fn(),
+}));
+
+jest.mock('@librechat/api', () => ({
+ isEnabled: jest.fn(),
+ createAxiosInstance: jest.fn(() => ({
+ get: jest.fn(),
+ post: jest.fn(),
+ put: jest.fn(),
+ delete: jest.fn(),
+ })),
+ logAxiosError: jest.fn(),
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: {
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ },
+ createModels: jest.fn(() => ({
+ User: {},
+ Conversation: {},
+ Message: {},
+ SharedLink: {},
+ })),
+}));
+
+jest.mock('~/models/Conversation', () => ({
+ getConvosByCursor: jest.fn(),
+ getConvo: jest.fn(),
+ deleteConvos: jest.fn(),
+ saveConvo: jest.fn(),
+}));
+
+jest.mock('~/models/ToolCall', () => ({
+ deleteToolCalls: jest.fn(),
+}));
+
+jest.mock('~/models', () => ({
+ deleteAllSharedLinks: jest.fn(),
+ deleteConvoSharedLink: jest.fn(),
+}));
+
+jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next());
+
+jest.mock('~/server/middleware', () => ({
+ createImportLimiters: jest.fn(() => ({
+ importIpLimiter: (req, res, next) => next(),
+ importUserLimiter: (req, res, next) => next(),
+ })),
+ createForkLimiters: jest.fn(() => ({
+ forkIpLimiter: (req, res, next) => next(),
+ forkUserLimiter: (req, res, next) => next(),
+ })),
+ configMiddleware: (req, res, next) => next(),
+}));
+
+jest.mock('~/server/utils/import/fork', () => ({
+ forkConversation: jest.fn(),
+ duplicateConversation: jest.fn(),
+}));
+
+jest.mock('~/server/utils/import', () => ({
+ importConversations: jest.fn(),
+}));
+
+jest.mock('~/cache/getLogStores', () => jest.fn());
+
+jest.mock('~/server/routes/files/multer', () => ({
+ storage: {},
+ importFileFilter: jest.fn(),
+}));
+
+jest.mock('multer', () => {
+ return jest.fn(() => ({
+ single: jest.fn(() => (req, res, next) => {
+ req.file = { path: '/tmp/test-file.json' };
+ next();
+ }),
+ }));
+});
+
+jest.mock('librechat-data-provider', () => ({
+ CacheKeys: {
+ GEN_TITLE: 'GEN_TITLE',
+ },
+ EModelEndpoint: {
+ azureAssistants: 'azureAssistants',
+ assistants: 'assistants',
+ },
+}));
+
+jest.mock('~/server/services/Endpoints/azureAssistants', () => ({
+ initializeClient: jest.fn(),
+}));
+
+jest.mock('~/server/services/Endpoints/assistants', () => ({
+ initializeClient: jest.fn(),
+}));
+
+describe('Convos Routes', () => {
+ let app;
+ let convosRouter;
+ const { deleteAllSharedLinks, deleteConvoSharedLink } = require('~/models');
+ const { deleteConvos } = require('~/models/Conversation');
+ const { deleteToolCalls } = require('~/models/ToolCall');
+
+ beforeAll(() => {
+ convosRouter = require('../convos');
+
+ app = express();
+ app.use(express.json());
+
+ /** Mock authenticated user */
+ app.use((req, res, next) => {
+ req.user = { id: 'test-user-123' };
+ next();
+ });
+
+ app.use('/api/convos', convosRouter);
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('DELETE /all', () => {
+ it('should delete all conversations, tool calls, and shared links for a user', async () => {
+ const mockDbResponse = {
+ deletedCount: 5,
+ message: 'All conversations deleted successfully',
+ };
+
+ deleteConvos.mockResolvedValue(mockDbResponse);
+ deleteToolCalls.mockResolvedValue({ deletedCount: 10 });
+ deleteAllSharedLinks.mockResolvedValue({
+ message: 'All shared links deleted successfully',
+ deletedCount: 3,
+ });
+
+ const response = await request(app).delete('/api/convos/all');
+
+ expect(response.status).toBe(201);
+ expect(response.body).toEqual(mockDbResponse);
+
+ /** Verify deleteConvos was called with correct userId */
+ expect(deleteConvos).toHaveBeenCalledWith('test-user-123', {});
+ expect(deleteConvos).toHaveBeenCalledTimes(1);
+
+ /** Verify deleteToolCalls was called with correct userId */
+ expect(deleteToolCalls).toHaveBeenCalledWith('test-user-123');
+ expect(deleteToolCalls).toHaveBeenCalledTimes(1);
+
+ /** Verify deleteAllSharedLinks was called with correct userId */
+ expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-123');
+ expect(deleteAllSharedLinks).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call deleteAllSharedLinks even when no conversations exist', async () => {
+ const mockDbResponse = {
+ deletedCount: 0,
+ message: 'No conversations to delete',
+ };
+
+ deleteConvos.mockResolvedValue(mockDbResponse);
+ deleteToolCalls.mockResolvedValue({ deletedCount: 0 });
+ deleteAllSharedLinks.mockResolvedValue({
+ message: 'All shared links deleted successfully',
+ deletedCount: 0,
+ });
+
+ const response = await request(app).delete('/api/convos/all');
+
+ expect(response.status).toBe(201);
+ expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-123');
+ });
+
+ it('should return 500 if deleteConvos fails', async () => {
+ const errorMessage = 'Database connection error';
+ deleteConvos.mockRejectedValue(new Error(errorMessage));
+
+ const response = await request(app).delete('/api/convos/all');
+
+ expect(response.status).toBe(500);
+ expect(response.text).toBe('Error clearing conversations');
+
+ /** Verify error was logged */
+ const { logger } = require('@librechat/data-schemas');
+ expect(logger.error).toHaveBeenCalledWith('Error clearing conversations', expect.any(Error));
+ });
+
+ it('should return 500 if deleteToolCalls fails', async () => {
+ deleteConvos.mockResolvedValue({ deletedCount: 5 });
+ deleteToolCalls.mockRejectedValue(new Error('Tool calls deletion failed'));
+
+ const response = await request(app).delete('/api/convos/all');
+
+ expect(response.status).toBe(500);
+ expect(response.text).toBe('Error clearing conversations');
+ });
+
+ it('should return 500 if deleteAllSharedLinks fails', async () => {
+ deleteConvos.mockResolvedValue({ deletedCount: 5 });
+ deleteToolCalls.mockResolvedValue({ deletedCount: 10 });
+ deleteAllSharedLinks.mockRejectedValue(new Error('Shared links deletion failed'));
+
+ const response = await request(app).delete('/api/convos/all');
+
+ expect(response.status).toBe(500);
+ expect(response.text).toBe('Error clearing conversations');
+ });
+
+ it('should handle multiple users independently', async () => {
+ /** First user */
+ deleteConvos.mockResolvedValue({ deletedCount: 3 });
+ deleteToolCalls.mockResolvedValue({ deletedCount: 5 });
+ deleteAllSharedLinks.mockResolvedValue({ deletedCount: 2 });
+
+ let response = await request(app).delete('/api/convos/all');
+
+ expect(response.status).toBe(201);
+ expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-123');
+
+ jest.clearAllMocks();
+
+ /** Second user (simulate different user by modifying middleware) */
+ const app2 = express();
+ app2.use(express.json());
+ app2.use((req, res, next) => {
+ req.user = { id: 'test-user-456' };
+ next();
+ });
+ app2.use('/api/convos', require('../convos'));
+
+ deleteConvos.mockResolvedValue({ deletedCount: 7 });
+ deleteToolCalls.mockResolvedValue({ deletedCount: 12 });
+ deleteAllSharedLinks.mockResolvedValue({ deletedCount: 4 });
+
+ response = await request(app2).delete('/api/convos/all');
+
+ expect(response.status).toBe(201);
+ expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-456');
+ });
+
+ it('should execute deletions in correct sequence', async () => {
+ const executionOrder = [];
+
+ deleteConvos.mockImplementation(() => {
+ executionOrder.push('deleteConvos');
+ return Promise.resolve({ deletedCount: 5 });
+ });
+
+ deleteToolCalls.mockImplementation(() => {
+ executionOrder.push('deleteToolCalls');
+ return Promise.resolve({ deletedCount: 10 });
+ });
+
+ deleteAllSharedLinks.mockImplementation(() => {
+ executionOrder.push('deleteAllSharedLinks');
+ return Promise.resolve({ deletedCount: 3 });
+ });
+
+ await request(app).delete('/api/convos/all');
+
+ /** Verify all three functions were called */
+ expect(executionOrder).toEqual(['deleteConvos', 'deleteToolCalls', 'deleteAllSharedLinks']);
+ });
+
+ it('should maintain data integrity by cleaning up shared links when conversations are deleted', async () => {
+ /** This test ensures that orphaned shared links are prevented */
+ const mockConvosDeleted = { deletedCount: 10 };
+ const mockToolCallsDeleted = { deletedCount: 15 };
+ const mockSharedLinksDeleted = {
+ message: 'All shared links deleted successfully',
+ deletedCount: 8,
+ };
+
+ deleteConvos.mockResolvedValue(mockConvosDeleted);
+ deleteToolCalls.mockResolvedValue(mockToolCallsDeleted);
+ deleteAllSharedLinks.mockResolvedValue(mockSharedLinksDeleted);
+
+ const response = await request(app).delete('/api/convos/all');
+
+ expect(response.status).toBe(201);
+
+ /** Verify that shared links cleanup was called for the same user */
+ expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-123');
+
+ /** Verify no shared links remain for deleted conversations */
+ expect(deleteAllSharedLinks).toHaveBeenCalledAfter(deleteConvos);
+ });
+ });
+
+ describe('DELETE /', () => {
+ it('should delete a single conversation, tool calls, and associated shared links', async () => {
+ const mockConversationId = 'conv-123';
+ const mockDbResponse = {
+ deletedCount: 1,
+ message: 'Conversation deleted successfully',
+ };
+
+ deleteConvos.mockResolvedValue(mockDbResponse);
+ deleteToolCalls.mockResolvedValue({ deletedCount: 3 });
+ deleteConvoSharedLink.mockResolvedValue({
+ message: 'Shared links deleted successfully',
+ deletedCount: 1,
+ });
+
+ const response = await request(app)
+ .delete('/api/convos')
+ .send({
+ arg: {
+ conversationId: mockConversationId,
+ },
+ });
+
+ expect(response.status).toBe(201);
+ expect(response.body).toEqual(mockDbResponse);
+
+ /** Verify deleteConvos was called with correct parameters */
+ expect(deleteConvos).toHaveBeenCalledWith('test-user-123', {
+ conversationId: mockConversationId,
+ });
+
+ /** Verify deleteToolCalls was called */
+ expect(deleteToolCalls).toHaveBeenCalledWith('test-user-123', mockConversationId);
+
+ /** Verify deleteConvoSharedLink was called */
+ expect(deleteConvoSharedLink).toHaveBeenCalledWith('test-user-123', mockConversationId);
+ });
+
+ it('should not call deleteConvoSharedLink when no conversationId provided', async () => {
+ deleteConvos.mockResolvedValue({ deletedCount: 0 });
+ deleteToolCalls.mockResolvedValue({ deletedCount: 0 });
+
+ const response = await request(app)
+ .delete('/api/convos')
+ .send({
+ arg: {
+ source: 'button',
+ },
+ });
+
+ expect(response.status).toBe(200);
+ expect(deleteConvoSharedLink).not.toHaveBeenCalled();
+ });
+
+ it('should handle deletion of conversation without shared links', async () => {
+ const mockConversationId = 'conv-no-shares';
+
+ deleteConvos.mockResolvedValue({ deletedCount: 1 });
+ deleteToolCalls.mockResolvedValue({ deletedCount: 0 });
+ deleteConvoSharedLink.mockResolvedValue({
+ message: 'Shared links deleted successfully',
+ deletedCount: 0,
+ });
+
+ const response = await request(app)
+ .delete('/api/convos')
+ .send({
+ arg: {
+ conversationId: mockConversationId,
+ },
+ });
+
+ expect(response.status).toBe(201);
+ expect(deleteConvoSharedLink).toHaveBeenCalledWith('test-user-123', mockConversationId);
+ });
+
+ it('should return 400 when no parameters provided', async () => {
+ const response = await request(app).delete('/api/convos').send({
+ arg: {},
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ error: 'no parameters provided' });
+ expect(deleteConvos).not.toHaveBeenCalled();
+ expect(deleteConvoSharedLink).not.toHaveBeenCalled();
+ });
+
+ it('should return 500 if deleteConvoSharedLink fails', async () => {
+ const mockConversationId = 'conv-error';
+
+ deleteConvos.mockResolvedValue({ deletedCount: 1 });
+ deleteToolCalls.mockResolvedValue({ deletedCount: 2 });
+ deleteConvoSharedLink.mockRejectedValue(new Error('Failed to delete shared links'));
+
+ const response = await request(app)
+ .delete('/api/convos')
+ .send({
+ arg: {
+ conversationId: mockConversationId,
+ },
+ });
+
+ expect(response.status).toBe(500);
+ expect(response.text).toBe('Error clearing conversations');
+ });
+
+ it('should execute deletions in correct sequence for single conversation', async () => {
+ const mockConversationId = 'conv-sequence';
+ const executionOrder = [];
+
+ deleteConvos.mockImplementation(() => {
+ executionOrder.push('deleteConvos');
+ return Promise.resolve({ deletedCount: 1 });
+ });
+
+ deleteToolCalls.mockImplementation(() => {
+ executionOrder.push('deleteToolCalls');
+ return Promise.resolve({ deletedCount: 2 });
+ });
+
+ deleteConvoSharedLink.mockImplementation(() => {
+ executionOrder.push('deleteConvoSharedLink');
+ return Promise.resolve({ deletedCount: 1 });
+ });
+
+ await request(app)
+ .delete('/api/convos')
+ .send({
+ arg: {
+ conversationId: mockConversationId,
+ },
+ });
+
+ expect(executionOrder).toEqual(['deleteConvos', 'deleteToolCalls', 'deleteConvoSharedLink']);
+ });
+
+ it('should prevent orphaned shared links when deleting single conversation', async () => {
+ const mockConversationId = 'conv-with-shares';
+
+ deleteConvos.mockResolvedValue({ deletedCount: 1 });
+ deleteToolCalls.mockResolvedValue({ deletedCount: 4 });
+ deleteConvoSharedLink.mockResolvedValue({
+ message: 'Shared links deleted successfully',
+ deletedCount: 2,
+ });
+
+ const response = await request(app)
+ .delete('/api/convos')
+ .send({
+ arg: {
+ conversationId: mockConversationId,
+ },
+ });
+
+ expect(response.status).toBe(201);
+
+ /** Verify shared links were deleted for the specific conversation */
+ expect(deleteConvoSharedLink).toHaveBeenCalledWith('test-user-123', mockConversationId);
+
+ /** Verify it was called after the conversation was deleted */
+ expect(deleteConvoSharedLink).toHaveBeenCalledAfter(deleteConvos);
+ });
+ });
+});
+
+/**
+ * Custom Jest matcher to verify function call order
+ */
+expect.extend({
+ toHaveBeenCalledAfter(received, other) {
+ const receivedCalls = received.mock.invocationCallOrder;
+ const otherCalls = other.mock.invocationCallOrder;
+
+ if (receivedCalls.length === 0) {
+ return {
+ pass: false,
+ message: () =>
+ `Expected ${received.getMockName()} to have been called after ${other.getMockName()}, but ${received.getMockName()} was never called`,
+ };
+ }
+
+ if (otherCalls.length === 0) {
+ return {
+ pass: false,
+ message: () =>
+ `Expected ${received.getMockName()} to have been called after ${other.getMockName()}, but ${other.getMockName()} was never called`,
+ };
+ }
+
+ const lastReceivedCall = receivedCalls[receivedCalls.length - 1];
+ const firstOtherCall = otherCalls[0];
+
+ const pass = lastReceivedCall > firstOtherCall;
+
+ return {
+ pass,
+ message: () =>
+ pass
+ ? `Expected ${received.getMockName()} not to have been called after ${other.getMockName()}`
+ : `Expected ${received.getMockName()} to have been called after ${other.getMockName()}`,
+ };
+ },
+});
diff --git a/api/server/routes/__tests__/ldap.spec.js b/api/server/routes/__tests__/ldap.spec.js
new file mode 100644
index 0000000000..bc7efa7859
--- /dev/null
+++ b/api/server/routes/__tests__/ldap.spec.js
@@ -0,0 +1,58 @@
+const express = require('express');
+const request = require('supertest');
+const { isEnabled } = require('@librechat/api');
+const { getLdapConfig } = require('~/server/services/Config/ldap');
+
+jest.mock('~/server/services/Config/ldap');
+jest.mock('@librechat/api', () => ({
+ ...jest.requireActual('@librechat/api'),
+ isEnabled: jest.fn(),
+}));
+
+const app = express();
+
+// Mock the route handler
+app.get('/api/config', (req, res) => {
+ const ldapConfig = getLdapConfig();
+ res.json({ ldap: ldapConfig });
+});
+
+describe('LDAP Config Tests', () => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('should return LDAP config with username property when LDAP_LOGIN_USES_USERNAME is enabled', async () => {
+ getLdapConfig.mockReturnValue({ enabled: true, username: true });
+ isEnabled.mockReturnValue(true);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.ldap).toEqual({
+ enabled: true,
+ username: true,
+ });
+ });
+
+ it('should return LDAP config without username property when LDAP_LOGIN_USES_USERNAME is not enabled', async () => {
+ getLdapConfig.mockReturnValue({ enabled: true });
+ isEnabled.mockReturnValue(false);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.ldap).toEqual({
+ enabled: true,
+ });
+ });
+
+ it('should not return LDAP config when LDAP is not enabled', async () => {
+ getLdapConfig.mockReturnValue(undefined);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.ldap).toBeUndefined();
+ });
+});
diff --git a/api/server/routes/__tests__/mcp.spec.js b/api/server/routes/__tests__/mcp.spec.js
new file mode 100644
index 0000000000..af038ba8d6
--- /dev/null
+++ b/api/server/routes/__tests__/mcp.spec.js
@@ -0,0 +1,1717 @@
+const express = require('express');
+const request = require('supertest');
+const mongoose = require('mongoose');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+
+const mockRegistryInstance = {
+ getServerConfig: jest.fn(),
+ getOAuthServers: jest.fn(),
+ getAllServerConfigs: jest.fn(),
+ addServer: jest.fn(),
+ updateServer: jest.fn(),
+ removeServer: jest.fn(),
+};
+
+jest.mock('@librechat/api', () => ({
+ ...jest.requireActual('@librechat/api'),
+ MCPOAuthHandler: {
+ initiateOAuthFlow: jest.fn(),
+ getFlowState: jest.fn(),
+ completeOAuthFlow: jest.fn(),
+ generateFlowId: jest.fn(),
+ },
+ MCPTokenStorage: {
+ storeTokens: jest.fn(),
+ getClientInfoAndMetadata: jest.fn(),
+ getTokens: jest.fn(),
+ deleteUserTokens: jest.fn(),
+ },
+ getUserMCPAuthMap: jest.fn(),
+ generateCheckAccess: jest.fn(() => (req, res, next) => next()),
+ MCPServersRegistry: {
+ getInstance: () => mockRegistryInstance,
+ },
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: {
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ },
+ createModels: jest.fn(() => ({
+ User: {
+ findOne: jest.fn(),
+ findById: jest.fn(),
+ },
+ Conversation: {
+ findOne: jest.fn(),
+ findById: jest.fn(),
+ },
+ })),
+ createMethods: jest.fn(() => ({
+ findUser: jest.fn(),
+ })),
+}));
+
+jest.mock('~/models', () => ({
+ findToken: jest.fn(),
+ updateToken: jest.fn(),
+ createToken: jest.fn(),
+ deleteTokens: jest.fn(),
+ findPluginAuthsByKeys: jest.fn(),
+ getRoleByName: jest.fn(),
+}));
+
+jest.mock('~/server/services/Config', () => ({
+ setCachedTools: jest.fn(),
+ getCachedTools: jest.fn(),
+ getMCPServerTools: jest.fn(),
+ loadCustomConfig: jest.fn(),
+}));
+
+jest.mock('~/server/services/Config/mcp', () => ({
+ updateMCPServerTools: jest.fn(),
+}));
+
+jest.mock('~/server/services/MCP', () => ({
+ getMCPSetupData: jest.fn(),
+ getServerConnectionStatus: jest.fn(),
+}));
+
+jest.mock('~/server/services/PluginService', () => ({
+ getUserPluginAuthValue: jest.fn(),
+}));
+
+jest.mock('~/config', () => ({
+ getMCPManager: jest.fn(),
+ getFlowStateManager: jest.fn(),
+ getOAuthReconnectionManager: jest.fn(),
+ getMCPServersRegistry: jest.fn(() => mockRegistryInstance),
+}));
+
+jest.mock('~/cache', () => ({
+ getLogStores: jest.fn(),
+}));
+
+jest.mock('~/server/middleware', () => ({
+ requireJwtAuth: (req, res, next) => next(),
+ canAccessMCPServerResource: () => (req, res, next) => next(),
+}));
+
+jest.mock('~/server/services/Tools/mcp', () => ({
+ reinitMCPServer: jest.fn(),
+}));
+
+describe('MCP Routes', () => {
+ let app;
+ let mongoServer;
+ let mcpRouter;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ await mongoose.connect(mongoServer.getUri());
+
+ require('~/db/models');
+
+ mcpRouter = require('../mcp');
+
+ app = express();
+ app.use(express.json());
+
+ app.use((req, res, next) => {
+ req.user = { id: 'test-user-id' };
+ next();
+ });
+
+ app.use('/api/mcp', mcpRouter);
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('GET /:serverName/oauth/initiate', () => {
+ const { MCPOAuthHandler } = require('@librechat/api');
+ const { getLogStores } = require('~/cache');
+
+ it('should initiate OAuth flow successfully', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ metadata: {
+ serverUrl: 'https://test-server.com',
+ oauth: { clientId: 'test-client-id' },
+ },
+ }),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+ mockRegistryInstance.getServerConfig.mockResolvedValue({});
+
+ MCPOAuthHandler.initiateOAuthFlow.mockResolvedValue({
+ authorizationUrl: 'https://oauth.example.com/auth',
+ flowId: 'test-flow-id',
+ });
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
+ userId: 'test-user-id',
+ flowId: 'test-flow-id',
+ });
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe('https://oauth.example.com/auth');
+ expect(MCPOAuthHandler.initiateOAuthFlow).toHaveBeenCalledWith(
+ 'test-server',
+ 'https://test-server.com',
+ 'test-user-id',
+ {},
+ { clientId: 'test-client-id' },
+ );
+ });
+
+ it('should return 403 when userId does not match authenticated user', async () => {
+ const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
+ userId: 'different-user-id',
+ flowId: 'test-flow-id',
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body).toEqual({ error: 'User mismatch' });
+ });
+
+ it('should return 404 when flow state is not found', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue(null),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
+ userId: 'test-user-id',
+ flowId: 'non-existent-flow-id',
+ });
+
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({ error: 'Flow not found' });
+ });
+
+ it('should return 400 when flow state has missing OAuth config', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ metadata: {
+ serverUrl: 'https://test-server.com',
+ },
+ }),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
+ userId: 'test-user-id',
+ flowId: 'test-flow-id',
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ error: 'Invalid flow state' });
+ });
+
+ it('should return 500 when OAuth initiation throws unexpected error', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockRejectedValue(new Error('Database error')),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
+ userId: 'test-user-id',
+ flowId: 'test-flow-id',
+ });
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ error: 'Failed to initiate OAuth' });
+ });
+
+ it('should return 400 when flow state metadata is null', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ id: 'test-flow-id',
+ metadata: null,
+ }),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
+ userId: 'test-user-id',
+ flowId: 'test-flow-id',
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ error: 'Invalid flow state' });
+ });
+ });
+
+ describe('GET /:serverName/oauth/callback', () => {
+ const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
+ const { getLogStores } = require('~/cache');
+
+ it('should redirect to error page when OAuth error is received', async () => {
+ const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
+ error: 'access_denied',
+ state: 'test-flow-id',
+ });
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe('/oauth/error?error=access_denied');
+ });
+
+ it('should redirect to error page when code is missing', async () => {
+ const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
+ state: 'test-flow-id',
+ });
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe('/oauth/error?error=missing_code');
+ });
+
+ it('should redirect to error page when state is missing', async () => {
+ const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
+ code: 'test-auth-code',
+ });
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe('/oauth/error?error=missing_state');
+ });
+
+ it('should redirect to error page when flow state is not found', async () => {
+ MCPOAuthHandler.getFlowState.mockResolvedValue(null);
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
+ code: 'test-auth-code',
+ state: 'invalid-flow-id',
+ });
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe('/oauth/error?error=invalid_state');
+ });
+
+ it('should handle OAuth callback successfully', async () => {
+ // mockRegistryInstance is defined at the top of the file
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
+ completeFlow: jest.fn().mockResolvedValue(),
+ deleteFlow: jest.fn().mockResolvedValue(true),
+ };
+ const mockFlowState = {
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ metadata: { toolFlowId: 'tool-flow-123' },
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ };
+ const mockTokens = {
+ access_token: 'test-access-token',
+ refresh_token: 'test-refresh-token',
+ };
+
+ MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+ mockRegistryInstance.getServerConfig.mockResolvedValue({});
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const mockUserConnection = {
+ fetchTools: jest.fn().mockResolvedValue([
+ {
+ name: 'test-tool',
+ description: 'A test tool',
+ inputSchema: { type: 'object' },
+ },
+ ]),
+ };
+ const mockMcpManager = {
+ getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
+ };
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+
+ const { getCachedTools, setCachedTools } = require('~/server/services/Config');
+ const { Constants } = require('librechat-data-provider');
+ getCachedTools.mockResolvedValue({
+ [`existing-tool${Constants.mcp_delimiter}test-server`]: { type: 'function' },
+ [`other-tool${Constants.mcp_delimiter}other-server`]: { type: 'function' },
+ });
+ setCachedTools.mockResolvedValue();
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
+ code: 'test-auth-code',
+ state: 'test-flow-id',
+ });
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
+ expect(MCPOAuthHandler.completeOAuthFlow).toHaveBeenCalledWith(
+ 'test-flow-id',
+ 'test-auth-code',
+ mockFlowManager,
+ {},
+ );
+ expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: 'test-user-id',
+ serverName: 'test-server',
+ tokens: mockTokens,
+ clientInfo: mockFlowState.clientInfo,
+ metadata: mockFlowState.metadata,
+ }),
+ );
+ const storeInvocation = MCPTokenStorage.storeTokens.mock.invocationCallOrder[0];
+ const connectInvocation = mockMcpManager.getUserConnection.mock.invocationCallOrder[0];
+ expect(storeInvocation).toBeLessThan(connectInvocation);
+ expect(mockFlowManager.completeFlow).toHaveBeenCalledWith(
+ 'tool-flow-123',
+ 'mcp_oauth',
+ mockTokens,
+ );
+ expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith('test-flow-id', 'mcp_get_tokens');
+ });
+
+ it('should redirect to error page when callback processing fails', async () => {
+ MCPOAuthHandler.getFlowState.mockRejectedValue(new Error('Callback error'));
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
+ code: 'test-auth-code',
+ state: 'test-flow-id',
+ });
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe('/oauth/error?error=callback_failed');
+ });
+
+ it('should handle system-level OAuth completion', async () => {
+ // mockRegistryInstance is defined at the top of the file
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
+ completeFlow: jest.fn().mockResolvedValue(),
+ deleteFlow: jest.fn().mockResolvedValue(true),
+ };
+ const mockFlowState = {
+ serverName: 'test-server',
+ userId: 'system',
+ metadata: { toolFlowId: 'tool-flow-123' },
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ };
+ const mockTokens = {
+ access_token: 'test-access-token',
+ refresh_token: 'test-refresh-token',
+ };
+
+ MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+ mockRegistryInstance.getServerConfig.mockResolvedValue({});
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
+ code: 'test-auth-code',
+ state: 'test-flow-id',
+ });
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
+ expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith('test-flow-id', 'mcp_get_tokens');
+ });
+
+ it('should handle reconnection failure after OAuth', async () => {
+ // mockRegistryInstance is defined at the top of the file
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
+ completeFlow: jest.fn().mockResolvedValue(),
+ deleteFlow: jest.fn().mockResolvedValue(true),
+ };
+ const mockFlowState = {
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ metadata: { toolFlowId: 'tool-flow-123' },
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ };
+ const mockTokens = {
+ access_token: 'test-access-token',
+ refresh_token: 'test-refresh-token',
+ };
+
+ MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+ mockRegistryInstance.getServerConfig.mockResolvedValue({});
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const mockMcpManager = {
+ getUserConnection: jest.fn().mockRejectedValue(new Error('Reconnection failed')),
+ };
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+
+ const { getCachedTools, setCachedTools } = require('~/server/services/Config');
+ getCachedTools.mockResolvedValue({});
+ setCachedTools.mockResolvedValue();
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
+ code: 'test-auth-code',
+ state: 'test-flow-id',
+ });
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
+ expect(MCPTokenStorage.storeTokens).toHaveBeenCalled();
+ expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith('test-flow-id', 'mcp_get_tokens');
+ });
+
+ it('should redirect to error page if token storage fails', async () => {
+ // mockRegistryInstance is defined at the top of the file
+ const mockFlowManager = {
+ completeFlow: jest.fn().mockResolvedValue(),
+ deleteFlow: jest.fn().mockResolvedValue(true),
+ };
+ const mockFlowState = {
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ metadata: { toolFlowId: 'tool-flow-123' },
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ };
+ const mockTokens = {
+ access_token: 'test-access-token',
+ refresh_token: 'test-refresh-token',
+ };
+
+ MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
+ MCPTokenStorage.storeTokens.mockRejectedValue(new Error('store failed'));
+ mockRegistryInstance.getServerConfig.mockResolvedValue({});
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const mockMcpManager = {
+ getUserConnection: jest.fn(),
+ };
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
+ code: 'test-auth-code',
+ state: 'test-flow-id',
+ });
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe('/oauth/error?error=callback_failed');
+ expect(mockMcpManager.getUserConnection).not.toHaveBeenCalled();
+ });
+
+ it('should use original flow state credentials when storing tokens', async () => {
+ // mockRegistryInstance is defined at the top of the file
+ const mockFlowManager = {
+ getFlowState: jest.fn(),
+ completeFlow: jest.fn().mockResolvedValue(),
+ deleteFlow: jest.fn().mockResolvedValue(true),
+ };
+ const clientInfo = {
+ client_id: 'client123',
+ client_secret: 'client_secret',
+ };
+ const flowState = {
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ metadata: { toolFlowId: 'tool-flow-123', serverUrl: 'http://example.com' },
+ clientInfo: clientInfo,
+ codeVerifier: 'test-verifier',
+ status: 'PENDING',
+ };
+ const mockTokens = {
+ access_token: 'test-access-token',
+ refresh_token: 'test-refresh-token',
+ };
+
+ // First call checks idempotency (status PENDING = not completed)
+ // Second call retrieves flow state for processing
+ mockFlowManager.getFlowState
+ .mockResolvedValueOnce({ status: 'PENDING' })
+ .mockResolvedValueOnce(flowState);
+
+ MCPOAuthHandler.getFlowState.mockResolvedValue(flowState);
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+ mockRegistryInstance.getServerConfig.mockResolvedValue({});
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const mockUserConnection = {
+ fetchTools: jest.fn().mockResolvedValue([]),
+ };
+ const mockMcpManager = {
+ getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
+ };
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+ require('~/config').getOAuthReconnectionManager = jest.fn().mockReturnValue({
+ clearReconnection: jest.fn(),
+ });
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
+ code: 'test-auth-code',
+ state: 'test-flow-id',
+ });
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
+
+ // Verify storeTokens was called with ORIGINAL flow state credentials
+ expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: 'test-user-id',
+ serverName: 'test-server',
+ tokens: mockTokens,
+ clientInfo: clientInfo, // Uses original flow state, not any "updated" credentials
+ metadata: flowState.metadata,
+ }),
+ );
+ });
+
+ it('should prevent duplicate token exchange with idempotency check', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn(),
+ };
+
+ // Flow is already completed
+ mockFlowManager.getFlowState.mockResolvedValue({
+ status: 'COMPLETED',
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ });
+
+ MCPOAuthHandler.getFlowState.mockResolvedValue({
+ status: 'COMPLETED',
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ });
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
+ code: 'test-auth-code',
+ state: 'test-flow-id',
+ });
+
+ expect(response.status).toBe(302);
+ expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
+
+ // Verify completeOAuthFlow was NOT called (prevented duplicate)
+ expect(MCPOAuthHandler.completeOAuthFlow).not.toHaveBeenCalled();
+ expect(MCPTokenStorage.storeTokens).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('GET /oauth/tokens/:flowId', () => {
+ const { getLogStores } = require('~/cache');
+
+ it('should return tokens for completed flow', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ status: 'COMPLETED',
+ result: {
+ access_token: 'test-access-token',
+ refresh_token: 'test-refresh-token',
+ },
+ }),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/oauth/tokens/test-user-id:flow-123');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ tokens: {
+ access_token: 'test-access-token',
+ refresh_token: 'test-refresh-token',
+ },
+ });
+ });
+
+ it('should return 401 when user is not authenticated', async () => {
+ const unauthApp = express();
+ unauthApp.use(express.json());
+ unauthApp.use((req, res, next) => {
+ req.user = null;
+ next();
+ });
+ unauthApp.use('/api/mcp', mcpRouter);
+
+ const response = await request(unauthApp).get('/api/mcp/oauth/tokens/test-flow-id');
+
+ expect(response.status).toBe(401);
+ expect(response.body).toEqual({ error: 'User not authenticated' });
+ });
+
+ it('should return 403 when user tries to access flow they do not own', async () => {
+ const response = await request(app).get('/api/mcp/oauth/tokens/other-user-id:flow-123');
+
+ expect(response.status).toBe(403);
+ expect(response.body).toEqual({ error: 'Access denied' });
+ });
+
+ it('should return 404 when flow is not found', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue(null),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get(
+ '/api/mcp/oauth/tokens/test-user-id:non-existent-flow',
+ );
+
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({ error: 'Flow not found' });
+ });
+
+ it('should return 400 when flow is not completed', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ status: 'PENDING',
+ result: null,
+ }),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/oauth/tokens/test-user-id:pending-flow');
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ error: 'Flow not completed' });
+ });
+
+ it('should return 500 when token retrieval throws an unexpected error', async () => {
+ getLogStores.mockImplementation(() => {
+ throw new Error('Database connection failed');
+ });
+
+ const response = await request(app).get('/api/mcp/oauth/tokens/test-user-id:error-flow');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ error: 'Failed to get tokens' });
+ });
+ });
+
+ describe('GET /oauth/status/:flowId', () => {
+ const { getLogStores } = require('~/cache');
+
+ it('should return flow status when flow exists', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ status: 'PENDING',
+ error: null,
+ }),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/oauth/status/test-flow-id');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ status: 'PENDING',
+ completed: false,
+ failed: false,
+ error: null,
+ });
+ });
+
+ it('should return 404 when flow is not found', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue(null),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/oauth/status/non-existent-flow');
+
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({ error: 'Flow not found' });
+ });
+
+ it('should return 500 when status check fails', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockRejectedValue(new Error('Database error')),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const response = await request(app).get('/api/mcp/oauth/status/error-flow-id');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ error: 'Failed to get flow status' });
+ });
+ });
+
+ describe('POST /oauth/cancel/:serverName', () => {
+ const { MCPOAuthHandler } = require('@librechat/api');
+ const { getLogStores } = require('~/cache');
+
+ it('should cancel OAuth flow successfully', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ status: 'PENDING',
+ }),
+ failFlow: jest.fn().mockResolvedValue(),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+ MCPOAuthHandler.generateFlowId.mockReturnValue('test-user-id:test-server');
+
+ const response = await request(app).post('/api/mcp/oauth/cancel/test-server');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ success: true,
+ message: 'OAuth flow for test-server cancelled successfully',
+ });
+
+ expect(mockFlowManager.failFlow).toHaveBeenCalledWith(
+ 'test-user-id:test-server',
+ 'mcp_oauth',
+ 'User cancelled OAuth flow',
+ );
+ });
+
+ it('should return success message when no active flow exists', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue(null),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+ MCPOAuthHandler.generateFlowId.mockReturnValue('test-user-id:test-server');
+
+ const response = await request(app).post('/api/mcp/oauth/cancel/test-server');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ success: true,
+ message: 'No active OAuth flow to cancel',
+ });
+ });
+
+ it('should return 500 when cancellation fails', async () => {
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
+ failFlow: jest.fn().mockRejectedValue(new Error('Database error')),
+ };
+
+ getLogStores.mockReturnValue({});
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+ MCPOAuthHandler.generateFlowId.mockReturnValue('test-user-id:test-server');
+
+ const response = await request(app).post('/api/mcp/oauth/cancel/test-server');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ error: 'Failed to cancel OAuth flow' });
+ });
+
+ it('should return 401 when user is not authenticated', async () => {
+ const unauthApp = express();
+ unauthApp.use(express.json());
+ unauthApp.use((req, res, next) => {
+ req.user = null;
+ next();
+ });
+ unauthApp.use('/api/mcp', mcpRouter);
+
+ const response = await request(unauthApp).post('/api/mcp/oauth/cancel/test-server');
+
+ expect(response.status).toBe(401);
+ expect(response.body).toEqual({ error: 'User not authenticated' });
+ });
+ });
+
+ describe('POST /:serverName/reinitialize', () => {
+ // mockRegistryInstance is defined at the top of the file
+
+ it('should return 404 when server is not found in configuration', async () => {
+ const mockMcpManager = {
+ disconnectUserConnection: jest.fn().mockResolvedValue(),
+ };
+
+ mockRegistryInstance.getServerConfig.mockResolvedValue(null);
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+ require('~/config').getFlowStateManager.mockReturnValue({});
+ require('~/cache').getLogStores.mockReturnValue({});
+
+ const response = await request(app).post('/api/mcp/non-existent-server/reinitialize');
+
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({
+ error: "MCP server 'non-existent-server' not found in configuration",
+ });
+ });
+
+ it('should handle OAuth requirement during reinitialize', async () => {
+ const mockMcpManager = {
+ disconnectUserConnection: jest.fn().mockResolvedValue(),
+ mcpConfigs: {},
+ getUserConnection: jest.fn().mockImplementation(async ({ oauthStart }) => {
+ if (oauthStart) {
+ await oauthStart('https://oauth.example.com/auth');
+ }
+ throw new Error('OAuth flow initiated - return early');
+ }),
+ };
+
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ customUserVars: {},
+ });
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+ require('~/config').getFlowStateManager.mockReturnValue({});
+ require('~/cache').getLogStores.mockReturnValue({});
+ require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
+ success: true,
+ message: "MCP server 'oauth-server' ready for OAuth authentication",
+ serverName: 'oauth-server',
+ oauthRequired: true,
+ oauthUrl: 'https://oauth.example.com/auth',
+ });
+
+ const response = await request(app).post('/api/mcp/oauth-server/reinitialize');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ success: true,
+ message: "MCP server 'oauth-server' ready for OAuth authentication",
+ serverName: 'oauth-server',
+ oauthRequired: true,
+ oauthUrl: 'https://oauth.example.com/auth',
+ });
+ });
+
+ it('should return 500 when reinitialize fails with non-OAuth error', async () => {
+ const mockMcpManager = {
+ disconnectUserConnection: jest.fn().mockResolvedValue(),
+ mcpConfigs: {},
+ getUserConnection: jest.fn().mockRejectedValue(new Error('Connection failed')),
+ };
+
+ mockRegistryInstance.getServerConfig.mockResolvedValue({});
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+ require('~/config').getFlowStateManager.mockReturnValue({});
+ require('~/cache').getLogStores.mockReturnValue({});
+ require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue(null);
+
+ const response = await request(app).post('/api/mcp/error-server/reinitialize');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({
+ error: 'Failed to reinitialize MCP server for user',
+ });
+ });
+
+ it('should return 500 when unexpected error occurs', async () => {
+ const mockMcpManager = {
+ disconnectUserConnection: jest.fn(),
+ };
+
+ mockRegistryInstance.getServerConfig.mockImplementation(() => {
+ throw new Error('Config loading failed');
+ });
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+
+ const response = await request(app).post('/api/mcp/test-server/reinitialize');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ error: 'Internal server error' });
+ });
+
+ it('should return 401 when user is not authenticated', async () => {
+ const unauthApp = express();
+ unauthApp.use(express.json());
+ unauthApp.use((req, res, next) => {
+ req.user = null;
+ next();
+ });
+ unauthApp.use('/api/mcp', mcpRouter);
+
+ const response = await request(unauthApp).post('/api/mcp/test-server/reinitialize');
+
+ expect(response.status).toBe(401);
+ expect(response.body).toEqual({ error: 'User not authenticated' });
+ });
+
+ it('should successfully reinitialize server and cache tools', async () => {
+ const mockUserConnection = {
+ fetchTools: jest.fn().mockResolvedValue([
+ { name: 'tool1', description: 'Test tool 1', inputSchema: { type: 'object' } },
+ { name: 'tool2', description: 'Test tool 2', inputSchema: { type: 'object' } },
+ ]),
+ };
+
+ const mockMcpManager = {
+ disconnectUserConnection: jest.fn().mockResolvedValue(),
+ getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
+ };
+
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ endpoint: 'http://test-server.com',
+ });
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+ require('~/config').getFlowStateManager.mockReturnValue({});
+ require('~/cache').getLogStores.mockReturnValue({});
+
+ const { getCachedTools, setCachedTools } = require('~/server/services/Config');
+ const { updateMCPServerTools } = require('~/server/services/Config/mcp');
+ getCachedTools.mockResolvedValue({});
+ setCachedTools.mockResolvedValue();
+ updateMCPServerTools.mockResolvedValue();
+
+ require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
+ success: true,
+ message: "MCP server 'test-server' reinitialized successfully",
+ serverName: 'test-server',
+ oauthRequired: false,
+ oauthUrl: null,
+ });
+
+ const response = await request(app).post('/api/mcp/test-server/reinitialize');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ success: true,
+ message: "MCP server 'test-server' reinitialized successfully",
+ serverName: 'test-server',
+ oauthRequired: false,
+ oauthUrl: null,
+ });
+ expect(mockMcpManager.disconnectUserConnection).toHaveBeenCalledWith(
+ 'test-user-id',
+ 'test-server',
+ );
+ });
+
+ it('should handle server with custom user variables', async () => {
+ const mockUserConnection = {
+ fetchTools: jest.fn().mockResolvedValue([]),
+ };
+
+ const mockMcpManager = {
+ disconnectUserConnection: jest.fn().mockResolvedValue(),
+ getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
+ };
+
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ endpoint: 'http://test-server.com',
+ customUserVars: {
+ API_KEY: 'some-env-var',
+ },
+ });
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+ require('~/config').getFlowStateManager.mockReturnValue({});
+ require('~/cache').getLogStores.mockReturnValue({});
+ require('@librechat/api').getUserMCPAuthMap.mockResolvedValue({
+ 'mcp:test-server': {
+ API_KEY: 'api-key-value',
+ },
+ });
+ require('~/models').findPluginAuthsByKeys.mockResolvedValue([
+ { key: 'API_KEY', value: 'api-key-value' },
+ ]);
+
+ const { getCachedTools, setCachedTools } = require('~/server/services/Config');
+ const { updateMCPServerTools } = require('~/server/services/Config/mcp');
+ getCachedTools.mockResolvedValue({});
+ setCachedTools.mockResolvedValue();
+ updateMCPServerTools.mockResolvedValue();
+
+ require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
+ success: true,
+ message: "MCP server 'test-server' reinitialized successfully",
+ serverName: 'test-server',
+ oauthRequired: false,
+ oauthUrl: null,
+ });
+
+ const response = await request(app).post('/api/mcp/test-server/reinitialize');
+
+ expect(response.status).toBe(200);
+ expect(response.body.success).toBe(true);
+ expect(require('@librechat/api').getUserMCPAuthMap).toHaveBeenCalledWith({
+ userId: 'test-user-id',
+ servers: ['test-server'],
+ findPluginAuthsByKeys: require('~/models').findPluginAuthsByKeys,
+ });
+ });
+ });
+
+ describe('GET /connection/status', () => {
+ const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
+
+ it('should return connection status for all servers', async () => {
+ const mockMcpConfig = {
+ server1: { endpoint: 'http://server1.com' },
+ server2: { endpoint: 'http://server2.com' },
+ };
+
+ getMCPSetupData.mockResolvedValue({
+ mcpConfig: mockMcpConfig,
+ appConnections: {},
+ userConnections: {},
+ oauthServers: [],
+ });
+
+ getServerConnectionStatus
+ .mockResolvedValueOnce({
+ connectionState: 'connected',
+ requiresOAuth: false,
+ })
+ .mockResolvedValueOnce({
+ connectionState: 'disconnected',
+ requiresOAuth: true,
+ });
+
+ const response = await request(app).get('/api/mcp/connection/status');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ success: true,
+ connectionStatus: {
+ server1: {
+ connectionState: 'connected',
+ requiresOAuth: false,
+ },
+ server2: {
+ connectionState: 'disconnected',
+ requiresOAuth: true,
+ },
+ },
+ });
+
+ expect(getMCPSetupData).toHaveBeenCalledWith('test-user-id');
+ expect(getServerConnectionStatus).toHaveBeenCalledTimes(2);
+ });
+
+ it('should return 404 when MCP config is not found', async () => {
+ getMCPSetupData.mockRejectedValue(new Error('MCP config not found'));
+
+ const response = await request(app).get('/api/mcp/connection/status');
+
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({ error: 'MCP config not found' });
+ });
+
+ it('should return 500 when connection status check fails', async () => {
+ getMCPSetupData.mockRejectedValue(new Error('Database error'));
+
+ const response = await request(app).get('/api/mcp/connection/status');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ error: 'Failed to get connection status' });
+ });
+
+ it('should return 401 when user is not authenticated', async () => {
+ const unauthApp = express();
+ unauthApp.use(express.json());
+ unauthApp.use((req, res, next) => {
+ req.user = null;
+ next();
+ });
+ unauthApp.use('/api/mcp', mcpRouter);
+
+ const response = await request(unauthApp).get('/api/mcp/connection/status');
+
+ expect(response.status).toBe(401);
+ expect(response.body).toEqual({ error: 'User not authenticated' });
+ });
+ });
+
+ describe('GET /connection/status/:serverName', () => {
+ const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
+
+ it('should return connection status for OAuth-required server', async () => {
+ const mockMcpConfig = {
+ 'oauth-server': { endpoint: 'http://oauth-server.com' },
+ };
+
+ getMCPSetupData.mockResolvedValue({
+ mcpConfig: mockMcpConfig,
+ appConnections: {},
+ userConnections: {},
+ oauthServers: [],
+ });
+
+ getServerConnectionStatus.mockResolvedValue({
+ connectionState: 'requires_auth',
+ requiresOAuth: true,
+ });
+
+ const response = await request(app).get('/api/mcp/connection/status/oauth-server');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ success: true,
+ serverName: 'oauth-server',
+ connectionStatus: 'requires_auth',
+ requiresOAuth: true,
+ });
+ });
+
+ it('should return 404 when server is not found in configuration', async () => {
+ getMCPSetupData.mockResolvedValue({
+ mcpConfig: {
+ 'other-server': { endpoint: 'http://other-server.com' },
+ },
+ appConnections: {},
+ userConnections: {},
+ oauthServers: [],
+ });
+
+ const response = await request(app).get('/api/mcp/connection/status/non-existent-server');
+
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({
+ error: "MCP server 'non-existent-server' not found in configuration",
+ });
+ });
+
+ it('should return 404 when MCP config is not found', async () => {
+ getMCPSetupData.mockRejectedValue(new Error('MCP config not found'));
+
+ const response = await request(app).get('/api/mcp/connection/status/test-server');
+
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({ error: 'MCP config not found' });
+ });
+
+ it('should return 500 when connection status check fails', async () => {
+ getMCPSetupData.mockRejectedValue(new Error('Database connection failed'));
+
+ const response = await request(app).get('/api/mcp/connection/status/test-server');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ error: 'Failed to get connection status' });
+ });
+
+ it('should return 401 when user is not authenticated', async () => {
+ const unauthApp = express();
+ unauthApp.use(express.json());
+ unauthApp.use((req, res, next) => {
+ req.user = null;
+ next();
+ });
+ unauthApp.use('/api/mcp', mcpRouter);
+
+ const response = await request(unauthApp).get('/api/mcp/connection/status/test-server');
+
+ expect(response.status).toBe(401);
+ expect(response.body).toEqual({ error: 'User not authenticated' });
+ });
+ });
+
+ describe('GET /:serverName/auth-values', () => {
+ const { getUserPluginAuthValue } = require('~/server/services/PluginService');
+ // mockRegistryInstance is defined at the top of the file
+
+ it('should return auth value flags for server', async () => {
+ const mockMcpManager = {};
+
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ customUserVars: {
+ API_KEY: 'some-env-var',
+ SECRET_TOKEN: 'another-env-var',
+ },
+ });
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+ getUserPluginAuthValue.mockResolvedValueOnce('some-api-key-value').mockResolvedValueOnce('');
+
+ const response = await request(app).get('/api/mcp/test-server/auth-values');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ success: true,
+ serverName: 'test-server',
+ authValueFlags: {
+ API_KEY: true,
+ SECRET_TOKEN: false,
+ },
+ });
+
+ expect(getUserPluginAuthValue).toHaveBeenCalledTimes(2);
+ });
+
+ it('should return 404 when server is not found in configuration', async () => {
+ const mockMcpManager = {};
+
+ mockRegistryInstance.getServerConfig.mockResolvedValue(null);
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+
+ const response = await request(app).get('/api/mcp/non-existent-server/auth-values');
+
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({
+ error: "MCP server 'non-existent-server' not found in configuration",
+ });
+ });
+
+ it('should handle errors when checking auth values', async () => {
+ const mockMcpManager = {};
+
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ customUserVars: {
+ API_KEY: 'some-env-var',
+ },
+ });
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+ getUserPluginAuthValue.mockRejectedValue(new Error('Database error'));
+
+ const response = await request(app).get('/api/mcp/test-server/auth-values');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ success: true,
+ serverName: 'test-server',
+ authValueFlags: {
+ API_KEY: false,
+ },
+ });
+ });
+
+ it('should return 500 when auth values check throws unexpected error', async () => {
+ const mockMcpManager = {};
+
+ mockRegistryInstance.getServerConfig.mockImplementation(() => {
+ throw new Error('Config loading failed');
+ });
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+
+ const response = await request(app).get('/api/mcp/test-server/auth-values');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ error: 'Failed to check auth value flags' });
+ });
+
+ it('should handle customUserVars that is not an object', async () => {
+ const mockMcpManager = {};
+
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ customUserVars: 'not-an-object',
+ });
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+
+ const response = await request(app).get('/api/mcp/test-server/auth-values');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ success: true,
+ serverName: 'test-server',
+ authValueFlags: {},
+ });
+ });
+
+ it('should return 401 when user is not authenticated in auth-values endpoint', async () => {
+ const appWithoutAuth = express();
+ appWithoutAuth.use(express.json());
+ appWithoutAuth.use('/api/mcp', mcpRouter);
+
+ const response = await request(appWithoutAuth).get('/api/mcp/test-server/auth-values');
+
+ expect(response.status).toBe(401);
+ expect(response.body).toEqual({ error: 'User not authenticated' });
+ });
+ });
+
+ describe('GET /:serverName/oauth/callback - Edge Cases', () => {
+ it('should handle OAuth callback without toolFlowId (falsy toolFlowId)', async () => {
+ const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
+ const mockTokens = {
+ access_token: 'edge-access-token',
+ refresh_token: 'edge-refresh-token',
+ };
+ MCPOAuthHandler.getFlowState = jest.fn().mockResolvedValue({
+ id: 'test-flow-id',
+ userId: 'test-user-id',
+ metadata: {
+ serverUrl: 'https://example.com',
+ oauth: {},
+ // No toolFlowId property
+ },
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ });
+ MCPOAuthHandler.completeOAuthFlow = jest.fn().mockResolvedValue(mockTokens);
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+ mockRegistryInstance.getServerConfig.mockResolvedValue({});
+
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
+ completeFlow: jest.fn(),
+ deleteFlow: jest.fn().mockResolvedValue(true),
+ };
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+
+ const mockMcpManager = {
+ getUserConnection: jest.fn().mockResolvedValue({
+ fetchTools: jest.fn().mockResolvedValue([]),
+ }),
+ };
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+
+ const response = await request(app)
+ .get('/api/mcp/test-server/oauth/callback?code=test-code&state=test-flow-id')
+ .expect(302);
+
+ expect(mockFlowManager.completeFlow).not.toHaveBeenCalled();
+ expect(response.headers.location).toContain('/oauth/success');
+ });
+
+ it('should handle null cached tools in OAuth callback (triggers || {} fallback)', async () => {
+ const { getCachedTools } = require('~/server/services/Config');
+ getCachedTools.mockResolvedValue(null);
+ const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
+ const mockTokens = {
+ access_token: 'edge-access-token',
+ refresh_token: 'edge-refresh-token',
+ };
+
+ const mockFlowManager = {
+ getFlowState: jest.fn().mockResolvedValue({
+ id: 'test-flow-id',
+ userId: 'test-user-id',
+ metadata: { serverUrl: 'https://example.com', oauth: {} },
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ }),
+ completeFlow: jest.fn(),
+ };
+ require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
+ MCPOAuthHandler.getFlowState.mockResolvedValue({
+ serverName: 'test-server',
+ userId: 'test-user-id',
+ metadata: { serverUrl: 'https://example.com', oauth: {} },
+ clientInfo: {},
+ codeVerifier: 'test-verifier',
+ });
+ MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
+ MCPTokenStorage.storeTokens.mockResolvedValue();
+ mockRegistryInstance.getServerConfig.mockResolvedValue({});
+
+ const mockMcpManager = {
+ getUserConnection: jest.fn().mockResolvedValue({
+ fetchTools: jest
+ .fn()
+ .mockResolvedValue([{ name: 'test-tool', description: 'Test tool' }]),
+ }),
+ };
+ require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
+
+ const response = await request(app)
+ .get('/api/mcp/test-server/oauth/callback?code=test-code&state=test-flow-id')
+ .expect(302);
+
+ expect(response.headers.location).toContain('/oauth/success');
+ });
+ });
+
+ describe('GET /servers', () => {
+ // mockRegistryInstance is defined at the top of the file
+
+ it('should return all server configs for authenticated user', async () => {
+ const mockServerConfigs = {
+ 'server-1': {
+ endpoint: 'http://server1.com',
+ name: 'Server 1',
+ },
+ 'server-2': {
+ endpoint: 'http://server2.com',
+ name: 'Server 2',
+ },
+ };
+
+ mockRegistryInstance.getAllServerConfigs.mockResolvedValue(mockServerConfigs);
+
+ const response = await request(app).get('/api/mcp/servers');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual(mockServerConfigs);
+ expect(mockRegistryInstance.getAllServerConfigs).toHaveBeenCalledWith('test-user-id');
+ });
+
+ it('should return empty object when no servers are configured', async () => {
+ mockRegistryInstance.getAllServerConfigs.mockResolvedValue({});
+
+ const response = await request(app).get('/api/mcp/servers');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({});
+ });
+
+ it('should return 401 when user is not authenticated', async () => {
+ const unauthApp = express();
+ unauthApp.use(express.json());
+ unauthApp.use((req, _res, next) => {
+ req.user = null;
+ next();
+ });
+ unauthApp.use('/api/mcp', mcpRouter);
+
+ const response = await request(unauthApp).get('/api/mcp/servers');
+
+ expect(response.status).toBe(401);
+ expect(response.body).toEqual({ message: 'Unauthorized' });
+ });
+
+ it('should return 500 when server config retrieval fails', async () => {
+ mockRegistryInstance.getAllServerConfigs.mockRejectedValue(new Error('Database error'));
+
+ const response = await request(app).get('/api/mcp/servers');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ error: 'Database error' });
+ });
+ });
+
+ describe('POST /servers', () => {
+ it('should create MCP server with valid SSE config', async () => {
+ const validConfig = {
+ type: 'sse',
+ url: 'https://mcp-server.example.com/sse',
+ title: 'Test SSE Server',
+ description: 'A test SSE server',
+ };
+
+ mockRegistryInstance.addServer.mockResolvedValue({
+ serverName: 'test-sse-server',
+ config: validConfig,
+ });
+
+ const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
+
+ expect(response.status).toBe(201);
+ expect(response.body).toEqual({
+ serverName: 'test-sse-server',
+ ...validConfig,
+ });
+ expect(mockRegistryInstance.addServer).toHaveBeenCalledWith(
+ 'temp_server_name',
+ expect.objectContaining({
+ type: 'sse',
+ url: 'https://mcp-server.example.com/sse',
+ }),
+ 'DB',
+ 'test-user-id',
+ );
+ });
+
+ it('should create MCP server with valid stdio config', async () => {
+ const validConfig = {
+ type: 'stdio',
+ command: 'node',
+ args: ['server.js'],
+ title: 'Test Stdio Server',
+ };
+
+ mockRegistryInstance.addServer.mockResolvedValue({
+ serverName: 'test-stdio-server',
+ config: validConfig,
+ });
+
+ const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
+
+ expect(response.status).toBe(201);
+ expect(response.body.serverName).toBe('test-stdio-server');
+ });
+
+ it('should return 400 for invalid configuration', async () => {
+ const invalidConfig = {
+ type: 'sse',
+ // Missing required 'url' field
+ title: 'Invalid Server',
+ };
+
+ const response = await request(app).post('/api/mcp/servers').send({ config: invalidConfig });
+
+ expect(response.status).toBe(400);
+ expect(response.body.message).toBe('Invalid configuration');
+ expect(response.body.errors).toBeDefined();
+ });
+
+ it('should return 400 for SSE config with invalid URL protocol', async () => {
+ const invalidConfig = {
+ type: 'sse',
+ url: 'ws://invalid-protocol.example.com/sse',
+ title: 'Invalid Protocol Server',
+ };
+
+ const response = await request(app).post('/api/mcp/servers').send({ config: invalidConfig });
+
+ expect(response.status).toBe(400);
+ expect(response.body.message).toBe('Invalid configuration');
+ });
+
+ it('should return 500 when registry throws error', async () => {
+ const validConfig = {
+ type: 'sse',
+ url: 'https://mcp-server.example.com/sse',
+ title: 'Test Server',
+ };
+
+ mockRegistryInstance.addServer.mockRejectedValue(new Error('Database connection failed'));
+
+ const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ message: 'Database connection failed' });
+ });
+ });
+
+ describe('GET /servers/:serverName', () => {
+ it('should return server config when found', async () => {
+ const mockConfig = {
+ type: 'sse',
+ url: 'https://mcp-server.example.com/sse',
+ title: 'Test Server',
+ };
+
+ mockRegistryInstance.getServerConfig.mockResolvedValue(mockConfig);
+
+ const response = await request(app).get('/api/mcp/servers/test-server');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual(mockConfig);
+ expect(mockRegistryInstance.getServerConfig).toHaveBeenCalledWith(
+ 'test-server',
+ 'test-user-id',
+ );
+ });
+
+ it('should return 404 when server not found', async () => {
+ mockRegistryInstance.getServerConfig.mockResolvedValue(null);
+
+ const response = await request(app).get('/api/mcp/servers/non-existent-server');
+
+ expect(response.status).toBe(404);
+ expect(response.body).toEqual({ message: 'MCP server not found' });
+ });
+
+ it('should return 500 when registry throws error', async () => {
+ mockRegistryInstance.getServerConfig.mockRejectedValue(new Error('Database error'));
+
+ const response = await request(app).get('/api/mcp/servers/error-server');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ message: 'Database error' });
+ });
+ });
+
+ describe('PATCH /servers/:serverName', () => {
+ it('should update server with valid config', async () => {
+ const updatedConfig = {
+ type: 'sse',
+ url: 'https://updated-mcp-server.example.com/sse',
+ title: 'Updated Server',
+ description: 'Updated description',
+ };
+
+ mockRegistryInstance.updateServer.mockResolvedValue(updatedConfig);
+
+ const response = await request(app)
+ .patch('/api/mcp/servers/test-server')
+ .send({ config: updatedConfig });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual(updatedConfig);
+ expect(mockRegistryInstance.updateServer).toHaveBeenCalledWith(
+ 'test-server',
+ expect.objectContaining({
+ type: 'sse',
+ url: 'https://updated-mcp-server.example.com/sse',
+ }),
+ 'DB',
+ 'test-user-id',
+ );
+ });
+
+ it('should return 400 for invalid configuration', async () => {
+ const invalidConfig = {
+ type: 'sse',
+ // Missing required 'url' field
+ title: 'Invalid Update',
+ };
+
+ const response = await request(app)
+ .patch('/api/mcp/servers/test-server')
+ .send({ config: invalidConfig });
+
+ expect(response.status).toBe(400);
+ expect(response.body.message).toBe('Invalid configuration');
+ expect(response.body.errors).toBeDefined();
+ });
+
+ it('should return 500 when registry throws error', async () => {
+ const validConfig = {
+ type: 'sse',
+ url: 'https://mcp-server.example.com/sse',
+ title: 'Test Server',
+ };
+
+ mockRegistryInstance.updateServer.mockRejectedValue(new Error('Update failed'));
+
+ const response = await request(app)
+ .patch('/api/mcp/servers/test-server')
+ .send({ config: validConfig });
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ message: 'Update failed' });
+ });
+ });
+
+ describe('DELETE /servers/:serverName', () => {
+ it('should delete server successfully', async () => {
+ mockRegistryInstance.removeServer.mockResolvedValue(undefined);
+
+ const response = await request(app).delete('/api/mcp/servers/test-server');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ message: 'MCP server deleted successfully' });
+ expect(mockRegistryInstance.removeServer).toHaveBeenCalledWith(
+ 'test-server',
+ 'DB',
+ 'test-user-id',
+ );
+ });
+
+ it('should return 500 when registry throws error', async () => {
+ mockRegistryInstance.removeServer.mockRejectedValue(new Error('Deletion failed'));
+
+ const response = await request(app).delete('/api/mcp/servers/error-server');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({ message: 'Deletion failed' });
+ });
+ });
+});
diff --git a/api/server/routes/__tests__/static.spec.js b/api/server/routes/__tests__/static.spec.js
new file mode 100644
index 0000000000..239ad7e095
--- /dev/null
+++ b/api/server/routes/__tests__/static.spec.js
@@ -0,0 +1,162 @@
+const fs = require('fs');
+const path = require('path');
+const express = require('express');
+const request = require('supertest');
+const zlib = require('zlib');
+
+// Create test setup
+const mockTestDir = path.join(__dirname, 'test-static-route');
+
+// Mock the paths module to point to our test directory
+jest.mock('~/config/paths', () => ({
+ imageOutput: mockTestDir,
+}));
+
+describe('Static Route Integration', () => {
+ let app;
+ let staticRoute;
+ let testDir;
+ let testImagePath;
+
+ beforeAll(() => {
+ // Create a test directory and files
+ testDir = mockTestDir;
+ testImagePath = path.join(testDir, 'test-image.jpg');
+
+ if (!fs.existsSync(testDir)) {
+ fs.mkdirSync(testDir, { recursive: true });
+ }
+
+ // Create a test image file
+ fs.writeFileSync(testImagePath, 'fake-image-data');
+
+ // Create a gzipped version of the test image (for gzip scanning tests)
+ fs.writeFileSync(testImagePath + '.gz', zlib.gzipSync('fake-image-data'));
+ });
+
+ afterAll(() => {
+ // Clean up test files
+ if (fs.existsSync(testDir)) {
+ fs.rmSync(testDir, { recursive: true, force: true });
+ }
+ });
+
+ // Helper function to set up static route with specific config
+ const setupStaticRoute = (skipGzipScan = false) => {
+ if (skipGzipScan) {
+ delete process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN;
+ } else {
+ process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN = 'true';
+ }
+
+ staticRoute = require('../static');
+ app.use('/images', staticRoute);
+ };
+
+ beforeEach(() => {
+ // Clear the module cache to get fresh imports
+ jest.resetModules();
+
+ app = express();
+
+ // Clear environment variables
+ delete process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN;
+ delete process.env.NODE_ENV;
+ });
+
+ describe('route functionality', () => {
+ it('should serve static image files', async () => {
+ process.env.NODE_ENV = 'production';
+ setupStaticRoute();
+
+ const response = await request(app).get('/images/test-image.jpg').expect(200);
+
+ expect(response.body.toString()).toBe('fake-image-data');
+ });
+
+ it('should return 404 for non-existent files', async () => {
+ setupStaticRoute();
+
+ const response = await request(app).get('/images/nonexistent.jpg');
+ expect(response.status).toBe(404);
+ });
+ });
+
+ describe('cache behavior', () => {
+ it('should set cache headers for images in production', async () => {
+ process.env.NODE_ENV = 'production';
+ setupStaticRoute();
+
+ const response = await request(app).get('/images/test-image.jpg').expect(200);
+
+ expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
+ });
+
+ it('should not set cache headers in development', async () => {
+ process.env.NODE_ENV = 'development';
+ setupStaticRoute();
+
+ const response = await request(app).get('/images/test-image.jpg').expect(200);
+
+ // Our middleware should not set the production cache-control header in development
+ expect(response.headers['cache-control']).not.toBe('public, max-age=172800, s-maxage=86400');
+ });
+ });
+
+ describe('gzip compression behavior', () => {
+ beforeEach(() => {
+ process.env.NODE_ENV = 'production';
+ });
+
+ it('should serve gzipped files when gzip scanning is enabled', async () => {
+ setupStaticRoute(false); // Enable gzip scanning
+
+ const response = await request(app)
+ .get('/images/test-image.jpg')
+ .set('Accept-Encoding', 'gzip')
+ .expect(200);
+
+ expect(response.headers['content-encoding']).toBe('gzip');
+ expect(response.body.toString()).toBe('fake-image-data');
+ });
+
+ it('should not serve gzipped files when gzip scanning is disabled', async () => {
+ setupStaticRoute(true); // Disable gzip scanning
+
+ const response = await request(app)
+ .get('/images/test-image.jpg')
+ .set('Accept-Encoding', 'gzip')
+ .expect(200);
+
+ expect(response.headers['content-encoding']).toBeUndefined();
+ expect(response.body.toString()).toBe('fake-image-data');
+ });
+ });
+
+ describe('path configuration', () => {
+ it('should use the configured imageOutput path', async () => {
+ setupStaticRoute();
+
+ const response = await request(app).get('/images/test-image.jpg').expect(200);
+
+ expect(response.body.toString()).toBe('fake-image-data');
+ });
+
+ it('should serve from subdirectories', async () => {
+ // Create a subdirectory with a file
+ const subDir = path.join(testDir, 'thumbs');
+ fs.mkdirSync(subDir, { recursive: true });
+ const thumbPath = path.join(subDir, 'thumb.jpg');
+ fs.writeFileSync(thumbPath, 'thumbnail-data');
+
+ setupStaticRoute();
+
+ const response = await request(app).get('/images/thumbs/thumb.jpg').expect(200);
+
+ expect(response.body.toString()).toBe('thumbnail-data');
+
+ // Clean up
+ fs.rmSync(subDir, { recursive: true, force: true });
+ });
+ });
+});
diff --git a/api/server/routes/accessPermissions.js b/api/server/routes/accessPermissions.js
new file mode 100644
index 0000000000..7431a86f1e
--- /dev/null
+++ b/api/server/routes/accessPermissions.js
@@ -0,0 +1,100 @@
+const express = require('express');
+const { ResourceType, PermissionBits } = require('librechat-data-provider');
+const {
+ getUserEffectivePermissions,
+ getAllEffectivePermissions,
+ updateResourcePermissions,
+ getResourcePermissions,
+ getResourceRoles,
+ searchPrincipals,
+} = require('~/server/controllers/PermissionsController');
+const { requireJwtAuth, checkBan, uaParser, canAccessResource } = require('~/server/middleware');
+const { checkPeoplePickerAccess } = require('~/server/middleware/checkPeoplePickerAccess');
+const { findMCPServerById } = require('~/models');
+
+const router = express.Router();
+
+// Apply common middleware
+router.use(requireJwtAuth);
+router.use(checkBan);
+router.use(uaParser);
+
+/**
+ * Generic routes for resource permissions
+ * Pattern: /api/permissions/{resourceType}/{resourceId}
+ */
+
+/**
+ * GET /api/permissions/search-principals
+ * Search for users and groups to grant permissions
+ */
+router.get('/search-principals', checkPeoplePickerAccess, searchPrincipals);
+
+/**
+ * GET /api/permissions/{resourceType}/roles
+ * Get available roles for a resource type
+ */
+router.get('/:resourceType/roles', getResourceRoles);
+
+/**
+ * GET /api/permissions/{resourceType}/{resourceId}
+ * Get all permissions for a specific resource
+ */
+router.get('/:resourceType/:resourceId', getResourcePermissions);
+
+/**
+ * PUT /api/permissions/{resourceType}/{resourceId}
+ * Bulk update permissions for a specific resource
+ */
+router.put(
+ '/:resourceType/:resourceId',
+ // Use middleware that dynamically handles resource type and permissions
+ (req, res, next) => {
+ const { resourceType } = req.params;
+ let middleware;
+
+ if (resourceType === ResourceType.AGENT) {
+ middleware = canAccessResource({
+ resourceType: ResourceType.AGENT,
+ requiredPermission: PermissionBits.SHARE,
+ resourceIdParam: 'resourceId',
+ });
+ } else if (resourceType === ResourceType.PROMPTGROUP) {
+ middleware = canAccessResource({
+ resourceType: ResourceType.PROMPTGROUP,
+ requiredPermission: PermissionBits.SHARE,
+ resourceIdParam: 'resourceId',
+ });
+ } else if (resourceType === ResourceType.MCPSERVER) {
+ middleware = canAccessResource({
+ resourceType: ResourceType.MCPSERVER,
+ requiredPermission: PermissionBits.SHARE,
+ resourceIdParam: 'resourceId',
+ idResolver: findMCPServerById,
+ });
+ } else {
+ return res.status(400).json({
+ error: 'Bad Request',
+ message: `Unsupported resource type: ${resourceType}`,
+ });
+ }
+
+ // Execute the middleware
+ middleware(req, res, next);
+ },
+ updateResourcePermissions,
+);
+
+/**
+ * GET /api/permissions/{resourceType}/effective/all
+ * Get user's effective permissions for all accessible resources of a type
+ */
+router.get('/:resourceType/effective/all', getAllEffectivePermissions);
+
+/**
+ * GET /api/permissions/{resourceType}/{resourceId}/effective
+ * Get user's effective permissions for a specific resource
+ */
+router.get('/:resourceType/:resourceId/effective', getUserEffectivePermissions);
+
+module.exports = router;
diff --git a/api/server/routes/actions.js b/api/server/routes/actions.js
new file mode 100644
index 0000000000..9f94f617ce
--- /dev/null
+++ b/api/server/routes/actions.js
@@ -0,0 +1,84 @@
+const express = require('express');
+const jwt = require('jsonwebtoken');
+const { getAccessToken } = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+const { CacheKeys } = require('librechat-data-provider');
+const { findToken, updateToken, createToken } = require('~/models');
+const { getFlowStateManager } = require('~/config');
+const { getLogStores } = require('~/cache');
+
+const router = express.Router();
+const JWT_SECRET = process.env.JWT_SECRET;
+
+/**
+ * Handles the OAuth callback and exchanges the authorization code for tokens.
+ *
+ * @route GET /actions/:action_id/oauth/callback
+ * @param {string} req.params.action_id - The ID of the action.
+ * @param {string} req.query.code - The authorization code returned by the provider.
+ * @param {string} req.query.state - The state token to verify the authenticity of the request.
+ * @returns {void} Sends a success message after updating the action with OAuth tokens.
+ */
+router.get('/:action_id/oauth/callback', async (req, res) => {
+ const { action_id } = req.params;
+ const { code, state } = req.query;
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getFlowStateManager(flowsCache);
+ let identifier = action_id;
+ try {
+ let decodedState;
+ try {
+ decodedState = jwt.verify(state, JWT_SECRET);
+ } catch (err) {
+ logger.error('Error verifying state parameter:', err);
+ await flowManager.failFlow(identifier, 'oauth', 'Invalid or expired state parameter');
+ return res.redirect('/oauth/error?error=invalid_state');
+ }
+
+ if (decodedState.action_id !== action_id) {
+ await flowManager.failFlow(identifier, 'oauth', 'Mismatched action ID in state parameter');
+ return res.redirect('/oauth/error?error=invalid_state');
+ }
+
+ if (!decodedState.user) {
+ await flowManager.failFlow(identifier, 'oauth', 'Invalid user ID in state parameter');
+ return res.redirect('/oauth/error?error=invalid_state');
+ }
+ identifier = `${decodedState.user}:${action_id}`;
+ const flowState = await flowManager.getFlowState(identifier, 'oauth');
+ if (!flowState) {
+ throw new Error('OAuth flow not found');
+ }
+
+ const tokenData = await getAccessToken(
+ {
+ code,
+ userId: decodedState.user,
+ identifier,
+ client_url: flowState.metadata.client_url,
+ redirect_uri: flowState.metadata.redirect_uri,
+ token_exchange_method: flowState.metadata.token_exchange_method,
+ /** Encrypted values */
+ encrypted_oauth_client_id: flowState.metadata.encrypted_oauth_client_id,
+ encrypted_oauth_client_secret: flowState.metadata.encrypted_oauth_client_secret,
+ },
+ {
+ findToken,
+ updateToken,
+ createToken,
+ },
+ );
+ await flowManager.completeFlow(identifier, 'oauth', tokenData);
+
+ /** Redirect to React success page */
+ const serverName = flowState.metadata?.action_name || `Action ${action_id}`;
+ const redirectUrl = `/oauth/success?serverName=${encodeURIComponent(serverName)}`;
+ res.redirect(redirectUrl);
+ } catch (error) {
+ logger.error('Error in OAuth callback:', error);
+ await flowManager.failFlow(identifier, 'oauth', error);
+ res.redirect('/oauth/error?error=callback_failed');
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/agents/actions.js b/api/server/routes/agents/actions.js
new file mode 100644
index 0000000000..12168ba28a
--- /dev/null
+++ b/api/server/routes/agents/actions.js
@@ -0,0 +1,264 @@
+const express = require('express');
+const { nanoid } = require('nanoid');
+const { logger } = require('@librechat/data-schemas');
+const { generateCheckAccess, isActionDomainAllowed } = require('@librechat/api');
+const {
+ Permissions,
+ ResourceType,
+ PermissionBits,
+ PermissionTypes,
+ actionDelimiter,
+ removeNullishValues,
+ validateActionDomain,
+ validateAndParseOpenAPISpec,
+} = require('librechat-data-provider');
+const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
+const { findAccessibleResources } = require('~/server/services/PermissionService');
+const { getAgent, updateAgent, getListAgentsByAccess } = require('~/models/Agent');
+const { updateAction, getActions, deleteAction } = require('~/models/Action');
+const { canAccessAgentResource } = require('~/server/middleware');
+const { getRoleByName } = require('~/models/Role');
+
+const router = express.Router();
+
+const checkAgentCreate = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE, Permissions.CREATE],
+ getRoleByName,
+});
+
+/**
+ * Retrieves all user's actions
+ * @route GET /actions/
+ * @param {string} req.params.id - Assistant identifier.
+ * @returns {Action[]} 200 - success response - application/json
+ */
+router.get('/', async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const editableAgentObjectIds = await findAccessibleResources({
+ userId,
+ role: req.user.role,
+ resourceType: ResourceType.AGENT,
+ requiredPermissions: PermissionBits.EDIT,
+ });
+
+ const agentsResponse = await getListAgentsByAccess({
+ accessibleIds: editableAgentObjectIds,
+ });
+
+ const editableAgentIds = agentsResponse.data.map((agent) => agent.id);
+ const actions =
+ editableAgentIds.length > 0 ? await getActions({ agent_id: { $in: editableAgentIds } }) : [];
+
+ res.json(actions);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+/**
+ * Adds or updates actions for a specific agent.
+ * @route POST /actions/:agent_id
+ * @param {string} req.params.agent_id - The ID of the agent.
+ * @param {FunctionTool[]} req.body.functions - The functions to be added or updated.
+ * @param {string} [req.body.action_id] - Optional ID for the action.
+ * @param {ActionMetadata} req.body.metadata - Metadata for the action.
+ * @returns {Object} 200 - success response - application/json
+ */
+router.post(
+ '/:agent_id',
+ canAccessAgentResource({
+ requiredPermission: PermissionBits.EDIT,
+ resourceIdParam: 'agent_id',
+ }),
+ checkAgentCreate,
+ async (req, res) => {
+ try {
+ const { agent_id } = req.params;
+
+ /** @type {{ functions: FunctionTool[], action_id: string, metadata: ActionMetadata }} */
+ const { functions, action_id: _action_id, metadata: _metadata } = req.body;
+ if (!functions.length) {
+ return res.status(400).json({ message: 'No functions provided' });
+ }
+
+ let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
+ const appConfig = req.config;
+
+ // SECURITY: Validate the OpenAPI spec and extract the server URL
+ if (metadata.raw_spec) {
+ const validationResult = validateAndParseOpenAPISpec(metadata.raw_spec);
+ if (!validationResult.status || !validationResult.serverUrl) {
+ return res.status(400).json({
+ message: validationResult.message || 'Invalid OpenAPI specification',
+ });
+ }
+
+ // SECURITY: Validate the client-provided domain matches the spec's server URL domain
+ // This prevents SSRF attacks where an attacker provides a whitelisted domain
+ // but uses a different (potentially internal) URL in the raw_spec
+ const domainValidation = validateActionDomain(metadata.domain, validationResult.serverUrl);
+ if (!domainValidation.isValid) {
+ logger.warn(`Domain mismatch detected: ${domainValidation.message}`, {
+ userId: req.user.id,
+ agent_id,
+ });
+ return res.status(400).json({
+ message:
+ 'Domain mismatch: The domain in the OpenAPI spec does not match the provided domain',
+ });
+ }
+ }
+
+ const isDomainAllowed = await isActionDomainAllowed(
+ metadata.domain,
+ appConfig?.actions?.allowedDomains,
+ );
+ if (!isDomainAllowed) {
+ return res.status(400).json({ message: 'Domain not allowed' });
+ }
+
+ let { domain } = metadata;
+ domain = await domainParser(domain, true);
+
+ if (!domain) {
+ return res.status(400).json({ message: 'No domain provided' });
+ }
+
+ const action_id = _action_id ?? nanoid();
+ const initialPromises = [];
+
+ // Permissions already validated by middleware - load agent directly
+ initialPromises.push(getAgent({ id: agent_id }));
+ if (_action_id) {
+ initialPromises.push(getActions({ action_id }, true));
+ }
+
+ /** @type {[Agent, [Action|undefined]]} */
+ const [agent, actions_result] = await Promise.all(initialPromises);
+ if (!agent) {
+ return res.status(404).json({ message: 'Agent not found for adding action' });
+ }
+
+ if (actions_result && actions_result.length) {
+ const action = actions_result[0];
+ metadata = { ...action.metadata, ...metadata };
+ }
+
+ const { actions: _actions = [], author: agent_author } = agent ?? {};
+ const actions = [];
+ for (const action of _actions) {
+ const [_action_domain, current_action_id] = action.split(actionDelimiter);
+ if (current_action_id === action_id) {
+ continue;
+ }
+
+ actions.push(action);
+ }
+
+ actions.push(`${domain}${actionDelimiter}${action_id}`);
+
+ /** @type {string[]}} */
+ const { tools: _tools = [] } = agent;
+
+ const tools = _tools
+ .filter((tool) => !(tool && (tool.includes(domain) || tool.includes(action_id))))
+ .concat(functions.map((tool) => `${tool.function.name}${actionDelimiter}${domain}`));
+
+ // Force version update since actions are changing
+ const updatedAgent = await updateAgent(
+ { id: agent_id },
+ { tools, actions },
+ {
+ updatingUserId: req.user.id,
+ forceVersion: true,
+ },
+ );
+
+ // Only update user field for new actions
+ const actionUpdateData = { metadata, agent_id };
+ if (!actions_result || !actions_result.length) {
+ // For new actions, use the agent owner's user ID
+ actionUpdateData.user = agent_author || req.user.id;
+ }
+
+ /** @type {[Action]} */
+ const updatedAction = await updateAction({ action_id }, actionUpdateData);
+
+ const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
+ for (let field of sensitiveFields) {
+ if (updatedAction.metadata[field]) {
+ delete updatedAction.metadata[field];
+ }
+ }
+
+ res.json([updatedAgent, updatedAction]);
+ } catch (error) {
+ const message = 'Trouble updating the Agent Action';
+ logger.error(message, error);
+ res.status(500).json({ message });
+ }
+ },
+);
+
+/**
+ * Deletes an action for a specific agent.
+ * @route DELETE /actions/:agent_id/:action_id
+ * @param {string} req.params.agent_id - The ID of the agent.
+ * @param {string} req.params.action_id - The ID of the action to delete.
+ * @returns {Object} 200 - success response - application/json
+ */
+router.delete(
+ '/:agent_id/:action_id',
+ canAccessAgentResource({
+ requiredPermission: PermissionBits.EDIT,
+ resourceIdParam: 'agent_id',
+ }),
+ checkAgentCreate,
+ async (req, res) => {
+ try {
+ const { agent_id, action_id } = req.params;
+
+ // Permissions already validated by middleware - load agent directly
+ const agent = await getAgent({ id: agent_id });
+ if (!agent) {
+ return res.status(404).json({ message: 'Agent not found for deleting action' });
+ }
+
+ const { tools = [], actions = [] } = agent;
+
+ let domain = '';
+ const updatedActions = actions.filter((action) => {
+ if (action.includes(action_id)) {
+ [domain] = action.split(actionDelimiter);
+ return false;
+ }
+ return true;
+ });
+
+ domain = await domainParser(domain, true);
+
+ if (!domain) {
+ return res.status(400).json({ message: 'No domain provided' });
+ }
+
+ const updatedTools = tools.filter((tool) => !(tool && tool.includes(domain)));
+
+ // Force version update since actions are being removed
+ await updateAgent(
+ { id: agent_id },
+ { tools: updatedTools, actions: updatedActions },
+ { updatingUserId: req.user.id, forceVersion: true },
+ );
+ await deleteAction({ action_id });
+ res.status(200).json({ message: 'Action deleted successfully' });
+ } catch (error) {
+ const message = 'Trouble deleting the Agent Action';
+ logger.error(message, error);
+ res.status(500).json({ message });
+ }
+ },
+);
+
+module.exports = router;
diff --git a/api/server/routes/agents/chat.js b/api/server/routes/agents/chat.js
new file mode 100644
index 0000000000..7ac4ce811d
--- /dev/null
+++ b/api/server/routes/agents/chat.js
@@ -0,0 +1,61 @@
+const express = require('express');
+const { generateCheckAccess, skipAgentCheck } = require('@librechat/api');
+const { PermissionTypes, Permissions, PermissionBits } = require('librechat-data-provider');
+const {
+ setHeaders,
+ moderateText,
+ // validateModel,
+ validateConvoAccess,
+ buildEndpointOption,
+ canAccessAgentFromBody,
+} = require('~/server/middleware');
+const { initializeClient } = require('~/server/services/Endpoints/agents');
+const AgentController = require('~/server/controllers/agents/request');
+const addTitle = require('~/server/services/Endpoints/agents/title');
+const { getRoleByName } = require('~/models/Role');
+
+const router = express.Router();
+
+router.use(moderateText);
+
+const checkAgentAccess = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE],
+ skipCheck: skipAgentCheck,
+ getRoleByName,
+});
+const checkAgentResourceAccess = canAccessAgentFromBody({
+ requiredPermission: PermissionBits.VIEW,
+});
+
+router.use(checkAgentAccess);
+router.use(checkAgentResourceAccess);
+router.use(validateConvoAccess);
+router.use(buildEndpointOption);
+router.use(setHeaders);
+
+const controller = async (req, res, next) => {
+ await AgentController(req, res, next, initializeClient, addTitle);
+};
+
+/**
+ * @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('/', 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;
diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js
new file mode 100644
index 0000000000..b5e249b059
--- /dev/null
+++ b/api/server/routes/agents/index.js
@@ -0,0 +1,43 @@
+const express = require('express');
+const { isEnabled } = require('@librechat/api');
+const {
+ uaParser,
+ checkBan,
+ requireJwtAuth,
+ messageIpLimiter,
+ configMiddleware,
+ concurrentLimiter,
+ messageUserLimiter,
+} = require('~/server/middleware');
+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);
+
+const chatRouter = express.Router();
+chatRouter.use(configMiddleware);
+
+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;
diff --git a/api/server/routes/agents/tools.js b/api/server/routes/agents/tools.js
new file mode 100644
index 0000000000..ca512e98c2
--- /dev/null
+++ b/api/server/routes/agents/tools.js
@@ -0,0 +1,39 @@
+const express = require('express');
+const { callTool, verifyToolAuth, getToolCalls } = require('~/server/controllers/tools');
+const { getAvailableTools } = require('~/server/controllers/PluginController');
+const { toolCallLimiter } = require('~/server/middleware');
+
+const router = express.Router();
+
+/**
+ * Get a list of available tools for agents.
+ * @route GET /agents/tools
+ * @returns {TPlugin[]} 200 - application/json
+ */
+router.get('/', getAvailableTools);
+
+/**
+ * Get a list of tool calls.
+ * @route GET /agents/tools/calls
+ * @returns {ToolCallData[]} 200 - application/json
+ */
+router.get('/calls', getToolCalls);
+
+/**
+ * Verify authentication for a specific tool
+ * @route GET /agents/tools/:toolId/auth
+ * @param {string} toolId - The ID of the tool to verify
+ * @returns {{ authenticated?: boolean; message?: string }}
+ */
+router.get('/:toolId/auth', verifyToolAuth);
+
+/**
+ * Execute code for a specific tool
+ * @route POST /agents/tools/:toolId/call
+ * @param {string} toolId - The ID of the tool to execute
+ * @param {object} req.body - Request body
+ * @returns {object} Result of code execution
+ */
+router.post('/:toolId/call', toolCallLimiter, callTool);
+
+module.exports = router;
diff --git a/api/server/routes/agents/v1.js b/api/server/routes/agents/v1.js
new file mode 100644
index 0000000000..1e4f1c0118
--- /dev/null
+++ b/api/server/routes/agents/v1.js
@@ -0,0 +1,185 @@
+const express = require('express');
+const { generateCheckAccess } = require('@librechat/api');
+const { PermissionTypes, Permissions, PermissionBits } = require('librechat-data-provider');
+const { requireJwtAuth, configMiddleware, canAccessAgentResource } = require('~/server/middleware');
+const v1 = require('~/server/controllers/agents/v1');
+const { getRoleByName } = require('~/models/Role');
+const actions = require('./actions');
+const tools = require('./tools');
+
+const router = express.Router();
+const avatar = express.Router();
+
+const checkAgentAccess = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+});
+const checkAgentCreate = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE, Permissions.CREATE],
+ getRoleByName,
+});
+
+const checkGlobalAgentShare = generateCheckAccess({
+ permissionType: PermissionTypes.AGENTS,
+ permissions: [Permissions.USE, Permissions.CREATE],
+ bodyProps: {
+ [Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'],
+ },
+ getRoleByName,
+});
+
+router.use(requireJwtAuth);
+
+/**
+ * Agent actions route.
+ * @route GET|POST /agents/actions
+ */
+router.use('/actions', configMiddleware, actions);
+
+/**
+ * Get a list of available tools for agents.
+ * @route GET /agents/tools
+ */
+router.use('/tools', configMiddleware, tools);
+
+/**
+ * Get all agent categories with counts
+ * @route GET /agents/categories
+ */
+router.get('/categories', v1.getAgentCategories);
+/**
+ * Creates an agent.
+ * @route POST /agents
+ * @param {AgentCreateParams} req.body - The agent creation parameters.
+ * @returns {Agent} 201 - Success response - application/json
+ */
+router.post('/', checkAgentCreate, v1.createAgent);
+
+/**
+ * Retrieves basic agent information (VIEW permission required).
+ * Returns safe, non-sensitive agent data for viewing purposes.
+ * @route GET /agents/:id
+ * @param {string} req.params.id - Agent identifier.
+ * @returns {Agent} 200 - Basic agent info - application/json
+ */
+router.get(
+ '/:id',
+ checkAgentAccess,
+ canAccessAgentResource({
+ requiredPermission: PermissionBits.VIEW,
+ resourceIdParam: 'id',
+ }),
+ v1.getAgent,
+);
+
+/**
+ * Retrieves full agent details including sensitive configuration (EDIT permission required).
+ * Returns complete agent data for editing/configuration purposes.
+ * @route GET /agents/:id/expanded
+ * @param {string} req.params.id - Agent identifier.
+ * @returns {Agent} 200 - Full agent details - application/json
+ */
+router.get(
+ '/:id/expanded',
+ checkAgentAccess,
+ canAccessAgentResource({
+ requiredPermission: PermissionBits.EDIT,
+ resourceIdParam: 'id',
+ }),
+ (req, res) => v1.getAgent(req, res, true), // Expanded version
+);
+/**
+ * Updates an agent.
+ * @route PATCH /agents/:id
+ * @param {string} req.params.id - Agent identifier.
+ * @param {AgentUpdateParams} req.body - The agent update parameters.
+ * @returns {Agent} 200 - Success response - application/json
+ */
+router.patch(
+ '/:id',
+ checkGlobalAgentShare,
+ canAccessAgentResource({
+ requiredPermission: PermissionBits.EDIT,
+ resourceIdParam: 'id',
+ }),
+ v1.updateAgent,
+);
+
+/**
+ * Duplicates an agent.
+ * @route POST /agents/:id/duplicate
+ * @param {string} req.params.id - Agent identifier.
+ * @returns {Agent} 201 - Success response - application/json
+ */
+router.post(
+ '/:id/duplicate',
+ checkAgentCreate,
+ canAccessAgentResource({
+ requiredPermission: PermissionBits.VIEW,
+ resourceIdParam: 'id',
+ }),
+ v1.duplicateAgent,
+);
+
+/**
+ * Deletes an agent.
+ * @route DELETE /agents/:id
+ * @param {string} req.params.id - Agent identifier.
+ * @returns {Agent} 200 - success response - application/json
+ */
+router.delete(
+ '/:id',
+ checkAgentCreate,
+ canAccessAgentResource({
+ requiredPermission: PermissionBits.DELETE,
+ resourceIdParam: 'id',
+ }),
+ v1.deleteAgent,
+);
+
+/**
+ * Reverts an agent to a previous version.
+ * @route POST /agents/:id/revert
+ * @param {string} req.params.id - Agent identifier.
+ * @param {number} req.body.version_index - Index of the version to revert to.
+ * @returns {Agent} 200 - success response - application/json
+ */
+router.post(
+ '/:id/revert',
+ checkGlobalAgentShare,
+ canAccessAgentResource({
+ requiredPermission: PermissionBits.EDIT,
+ resourceIdParam: 'id',
+ }),
+ v1.revertAgentVersion,
+);
+
+/**
+ * Returns a list of agents.
+ * @route GET /agents
+ * @param {AgentListParams} req.query - The agent list parameters for pagination and sorting.
+ * @returns {AgentListResponse} 200 - success response - application/json
+ */
+router.get('/', checkAgentAccess, v1.getListAgents);
+
+/**
+ * Uploads and updates an avatar for a specific agent.
+ * @route POST /agents/:agent_id/avatar
+ * @param {string} req.params.agent_id - The ID of the agent.
+ * @param {Express.Multer.File} req.file - The avatar image file.
+ * @param {string} [req.body.metadata] - Optional metadata for the agent's avatar.
+ * @returns {Object} 200 - success response - application/json
+ */
+avatar.post(
+ '/:agent_id/avatar/',
+ checkAgentAccess,
+ canAccessAgentResource({
+ requiredPermission: PermissionBits.EDIT,
+ resourceIdParam: 'agent_id',
+ }),
+ v1.uploadAgentAvatar,
+);
+
+module.exports = { v1: router, avatar };
diff --git a/api/server/routes/ask.js b/api/server/routes/ask.js
deleted file mode 100644
index 23ed20ba85..0000000000
--- a/api/server/routes/ask.js
+++ /dev/null
@@ -1,153 +0,0 @@
-const express = require('express');
-const crypto = require('crypto');
-const router = express.Router();
-const askBing = require('./askBing');
-const askSydney = require('./askSydney');
-const {
- titleConvo,
- askClient,
- browserClient,
- customClient,
- detectCode
-} = require('../../app/');
-const { getConvo, saveMessage, deleteMessages, saveConvo } = require('../../models');
-const { handleError, sendMessage } = require('./handlers');
-
-router.use('/bing', askBing);
-router.use('/sydney', askSydney);
-
-router.post('/', async (req, res) => {
- let { model, text, parentMessageId, conversationId, chatGptLabel, promptPrefix } = req.body;
- if (!text.trim().includes(' ') && text.length < 5) {
- return handleError(res, 'Prompt empty or too short');
- }
-
- const userMessageId = crypto.randomUUID();
- let userMessage = { id: userMessageId, sender: 'User', text };
-
- console.log('ask log', {
- model,
- ...userMessage,
- parentMessageId,
- conversationId,
- chatGptLabel,
- promptPrefix
- });
-
- let client;
-
- if (model === 'chatgpt') {
- client = askClient;
- } else if (model === 'chatgptCustom') {
- client = customClient;
- } else {
- client = browserClient;
- }
-
- if (model === 'chatgptCustom' && !chatGptLabel && conversationId) {
- const convo = await getConvo({ conversationId });
- if (convo) {
- console.log('found convo for custom gpt', { convo })
- chatGptLabel = convo.chatGptLabel;
- promptPrefix = convo.promptPrefix;
- }
- }
-
- res.writeHead(200, {
- Connection: 'keep-alive',
- 'Content-Type': 'text/event-stream',
- 'Cache-Control': 'no-cache, no-transform',
- 'Access-Control-Allow-Origin': '*',
- 'X-Accel-Buffering': 'no'
- });
-
- try {
- let i = 0;
- let tokens = '';
- const progressCallback = async (partial) => {
- if (i === 0 && typeof partial === 'object') {
- userMessage.parentMessageId = parentMessageId ? parentMessageId : partial.id;
- userMessage.conversationId = conversationId ? conversationId : partial.conversationId;
- await saveMessage(userMessage);
- sendMessage(res, { ...partial, initial: true });
- i++;
- }
-
- if (typeof partial === 'object') {
- sendMessage(res, { ...partial, message: true });
- } else {
- tokens += partial === text ? '' : partial;
- if (tokens.includes('[DONE]')) {
- tokens = tokens.replace('[DONE]', '');
- }
-
- // tokens = await detectCode(tokens);
- sendMessage(res, { text: tokens, message: true, initial: i === 0 ? true : false });
- i++;
- }
- };
-
- let gptResponse = await client({
- text,
- progressCallback,
- convo: {
- parentMessageId,
- conversationId
- },
- chatGptLabel,
- promptPrefix
- });
-
- console.log('CLIENT RESPONSE', gptResponse);
-
- if (!gptResponse.parentMessageId) {
- gptResponse.text = gptResponse.response;
- gptResponse.id = gptResponse.messageId;
- gptResponse.parentMessageId = gptResponse.messageId;
- userMessage.parentMessageId = parentMessageId ? parentMessageId : gptResponse.messageId;
- userMessage.conversationId = conversationId
- ? conversationId
- : gptResponse.conversationId;
- await saveMessage(userMessage);
- delete gptResponse.response;
- }
-
- if (
- (gptResponse.text.includes('2023') && !gptResponse.text.trim().includes(' ')) ||
- gptResponse.text.toLowerCase().includes('no response') ||
- gptResponse.text.toLowerCase().includes('no answer')
- ) {
- return handleError(res, 'Prompt empty or too short');
- }
-
- if (!parentMessageId) {
- gptResponse.title = await titleConvo({
- model,
- message: text,
- response: JSON.stringify(gptResponse.text)
- });
- }
- gptResponse.sender = model === 'chatgptCustom' ? chatGptLabel : model;
- gptResponse.final = true;
- gptResponse.text = await detectCode(gptResponse.text);
-
- if (chatGptLabel?.length > 0 && model === 'chatgptCustom') {
- gptResponse.chatGptLabel = chatGptLabel;
- }
-
- if (promptPrefix?.length > 0 && model === 'chatgptCustom') {
- gptResponse.promptPrefix = promptPrefix;
- }
-
- await saveMessage(gptResponse);
- await saveConvo(gptResponse);
- sendMessage(res, gptResponse);
- res.end();
- } catch (error) {
- console.log(error);
- await deleteMessages({ id: userMessageId });
- handleError(res, error.message);
- }
-});
-
-module.exports = router;
diff --git a/api/server/routes/askBing.js b/api/server/routes/askBing.js
deleted file mode 100644
index 42718b9d91..0000000000
--- a/api/server/routes/askBing.js
+++ /dev/null
@@ -1,86 +0,0 @@
-const express = require('express');
-const crypto = require('crypto');
-const router = express.Router();
-const { titleConvo, getCitations, citeText, askBing } = require('../../app/');
-const { saveMessage, deleteMessages, saveConvo } = require('../../models');
-const { handleError, sendMessage } = require('./handlers');
-const citationRegex = /\[\^\d+?\^]/g;
-
-router.post('/', async (req, res) => {
- const { model, text, ...convo } = req.body;
- if (!text.trim().includes(' ') && text.length < 5) {
- return handleError(res, 'Prompt empty or too short');
- }
-
- const userMessageId = crypto.randomUUID();
- let userMessage = { id: userMessageId, sender: 'User', text };
-
- console.log('ask log', { model, ...userMessage, ...convo });
-
- res.writeHead(200, {
- Connection: 'keep-alive',
- 'Content-Type': 'text/event-stream',
- 'Cache-Control': 'no-cache, no-transform',
- 'Access-Control-Allow-Origin': '*',
- 'X-Accel-Buffering': 'no'
- });
-
- try {
- let tokens = '';
- const progressCallback = async (partial) => {
- tokens += partial === text ? '' : partial;
- // tokens = appendCode(tokens);
- tokens = citeText(tokens, true);
- sendMessage(res, { text: tokens, message: true });
- };
-
- let response = await askBing({
- text,
- progressCallback,
- convo
- });
-
- console.log('BING RESPONSE');
- // console.dir(response, { depth: null });
- const hasCitations = response.response.match(citationRegex)?.length > 0;
-
- userMessage.conversationSignature =
- convo.conversationSignature || response.conversationSignature;
- userMessage.conversationId = convo.conversationId || response.conversationId;
- userMessage.invocationId = response.invocationId;
- await saveMessage(userMessage);
-
- if (!convo.conversationSignature) {
- response.title = await titleConvo({
- model,
- message: text,
- response: JSON.stringify(response.response)
- });
- }
-
- response.text = response.response;
- delete response.response;
- response.id = response.details.messageId;
- response.suggestions =
- response.details.suggestedResponses &&
- response.details.suggestedResponses.map((s) => s.text);
- response.sender = model;
- response.final = true;
-
- const links = getCitations(response);
- response.text =
- citeText(response) +
- (links?.length > 0 && hasCitations ? `\n${links} ` : '');
-
- await saveMessage(response);
- await saveConvo(response);
- sendMessage(res, response);
- res.end();
- } catch (error) {
- console.log(error);
- await deleteMessages({ id: userMessageId });
- handleError(res, error.message);
- }
-});
-
-module.exports = router;
diff --git a/api/server/routes/askSydney.js b/api/server/routes/askSydney.js
deleted file mode 100644
index 149b8b1c45..0000000000
--- a/api/server/routes/askSydney.js
+++ /dev/null
@@ -1,96 +0,0 @@
-const express = require('express');
-const crypto = require('crypto');
-const router = express.Router();
-const { titleConvo, getCitations, citeText, askSydney } = require('../../app/');
-const { saveMessage, deleteMessages, saveConvo, getConvoTitle } = require('../../models');
-const { handleError, sendMessage } = require('./handlers');
-const citationRegex = /\[\^\d+?\^]/g;
-
-router.post('/', async (req, res) => {
- const { model, text, ...convo } = req.body;
- if (!text.trim().includes(' ') && text.length < 5) {
- return handleError(res, 'Prompt empty or too short');
- }
-
- const userMessageId = crypto.randomUUID();
- let userMessage = { id: userMessageId, sender: 'User', text };
-
- console.log('ask log', { model, ...userMessage, ...convo });
-
- res.writeHead(200, {
- Connection: 'keep-alive',
- 'Content-Type': 'text/event-stream',
- 'Cache-Control': 'no-cache, no-transform',
- 'Access-Control-Allow-Origin': '*',
- 'X-Accel-Buffering': 'no'
- });
-
- try {
- let tokens = '';
- const progressCallback = async (partial) => {
- tokens += partial === text ? '' : partial;
- // tokens = appendCode(tokens);
- tokens = citeText(tokens, true);
- sendMessage(res, { text: tokens, message: true });
- };
-
- let response = await askSydney({
- text,
- progressCallback,
- convo
- });
-
- console.log('SYDNEY RESPONSE');
- console.log(response.response);
- // console.dir(response, { depth: null });
- const hasCitations = response.response.match(citationRegex)?.length > 0;
-
- // Save sydney response
- response.id = response.messageId;
- // response.parentMessageId = convo.parentMessageId ? convo.parentMessageId : response.messageId;
- response.parentMessageId = response.messageId;
- response.invocationId = convo.invocationId ? convo.invocationId + 1 : 1;
- response.title = convo.jailbreakConversationId
- ? await getConvoTitle(convo.conversationId)
- : await titleConvo({
- model,
- message: text,
- response: JSON.stringify(response.response)
- });
- response.conversationId = convo.conversationId
- ? convo.conversationId
- : crypto.randomUUID();
- response.conversationSignature = convo.conversationSignature
- ? convo.conversationSignature
- : crypto.randomUUID();
- response.text = response.response;
- delete response.response;
- response.suggestions =
- response.details.suggestedResponses &&
- response.details.suggestedResponses.map((s) => s.text);
- response.sender = model;
- response.final = true;
-
- const links = getCitations(response);
- response.text =
- citeText(response) +
- (links?.length > 0 && hasCitations ? `\n${links} ` : '');
-
- // Save user message
- userMessage.conversationId = response.conversationId;
- userMessage.parentMessageId = response.parentMessageId;
- await saveMessage(userMessage);
-
- // Save sydney response & convo, then send
- await saveMessage(response);
- await saveConvo(response);
- sendMessage(res, response);
- res.end();
- } catch (error) {
- console.log(error);
- await deleteMessages({ id: userMessageId });
- handleError(res, error.message);
- }
-});
-
-module.exports = router;
diff --git a/api/server/routes/assistants/actions.js b/api/server/routes/assistants/actions.js
new file mode 100644
index 0000000000..57975d32a7
--- /dev/null
+++ b/api/server/routes/assistants/actions.js
@@ -0,0 +1,210 @@
+const express = require('express');
+const { nanoid } = require('nanoid');
+const { logger } = require('@librechat/data-schemas');
+const { isActionDomainAllowed } = require('@librechat/api');
+const { actionDelimiter, EModelEndpoint, removeNullishValues } = require('librechat-data-provider');
+const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
+const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
+const { updateAction, getActions, deleteAction } = require('~/models/Action');
+const { updateAssistantDoc, getAssistant } = require('~/models/Assistant');
+
+const router = express.Router();
+
+/**
+ * Adds or updates actions for a specific assistant.
+ * @route POST /actions/:assistant_id
+ * @param {string} req.params.assistant_id - The ID of the assistant.
+ * @param {FunctionTool[]} req.body.functions - The functions to be added or updated.
+ * @param {string} [req.body.action_id] - Optional ID for the action.
+ * @param {ActionMetadata} req.body.metadata - Metadata for the action.
+ * @returns {Object} 200 - success response - application/json
+ */
+router.post('/:assistant_id', async (req, res) => {
+ try {
+ const appConfig = req.config;
+ const { assistant_id } = req.params;
+
+ /** @type {{ functions: FunctionTool[], action_id: string, metadata: ActionMetadata }} */
+ const { functions, action_id: _action_id, metadata: _metadata } = req.body;
+ if (!functions.length) {
+ return res.status(400).json({ message: 'No functions provided' });
+ }
+
+ let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
+ const isDomainAllowed = await isActionDomainAllowed(
+ metadata.domain,
+ appConfig?.actions?.allowedDomains,
+ );
+ if (!isDomainAllowed) {
+ return res.status(400).json({ message: 'Domain not allowed' });
+ }
+
+ let { domain } = metadata;
+ domain = await domainParser(domain, true);
+
+ if (!domain) {
+ return res.status(400).json({ message: 'No domain provided' });
+ }
+
+ const action_id = _action_id ?? nanoid();
+ const initialPromises = [];
+
+ const { openai } = await getOpenAIClient({ req, res });
+
+ initialPromises.push(getAssistant({ assistant_id }));
+ initialPromises.push(openai.beta.assistants.retrieve(assistant_id));
+ !!_action_id && initialPromises.push(getActions({ action_id }, true));
+
+ /** @type {[AssistantDocument, Assistant, [Action|undefined]]} */
+ const [assistant_data, assistant, actions_result] = await Promise.all(initialPromises);
+
+ if (actions_result && actions_result.length) {
+ const action = actions_result[0];
+ metadata = { ...action.metadata, ...metadata };
+ }
+
+ if (!assistant) {
+ return res.status(404).json({ message: 'Assistant not found' });
+ }
+
+ const { actions: _actions = [], user: assistant_user } = assistant_data ?? {};
+ const actions = [];
+ for (const action of _actions) {
+ const [_action_domain, current_action_id] = action.split(actionDelimiter);
+ if (current_action_id === action_id) {
+ continue;
+ }
+
+ actions.push(action);
+ }
+
+ actions.push(`${domain}${actionDelimiter}${action_id}`);
+
+ /** @type {{ tools: FunctionTool[] | { type: 'code_interpreter'|'retrieval'}[]}} */
+ const { tools: _tools = [] } = assistant;
+
+ const tools = _tools
+ .filter(
+ (tool) =>
+ !(
+ tool.function &&
+ (tool.function.name.includes(domain) || tool.function.name.includes(action_id))
+ ),
+ )
+ .concat(
+ functions.map((tool) => ({
+ ...tool,
+ function: {
+ ...tool.function,
+ name: `${tool.function.name}${actionDelimiter}${domain}`,
+ },
+ })),
+ );
+
+ let updatedAssistant = await openai.beta.assistants.update(assistant_id, { tools });
+ const promises = [];
+
+ // Only update user field for new assistant documents
+ const assistantUpdateData = { actions };
+ if (!assistant_data) {
+ assistantUpdateData.user = req.user.id;
+ }
+ promises.push(updateAssistantDoc({ assistant_id }, assistantUpdateData));
+
+ // Only update user field for new actions
+ const actionUpdateData = { metadata, assistant_id };
+ if (!actions_result || !actions_result.length) {
+ // For new actions, use the assistant owner's user ID
+ actionUpdateData.user = assistant_user || req.user.id;
+ }
+ promises.push(updateAction({ action_id }, actionUpdateData));
+
+ /** @type {[AssistantDocument, Action]} */
+ let [assistantDocument, updatedAction] = await Promise.all(promises);
+ const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
+ for (let field of sensitiveFields) {
+ if (updatedAction.metadata[field]) {
+ delete updatedAction.metadata[field];
+ }
+ }
+
+ /* Map Azure OpenAI model to the assistant as defined by config */
+ if (appConfig.endpoints?.[EModelEndpoint.azureOpenAI]?.assistants) {
+ updatedAssistant = {
+ ...updatedAssistant,
+ model: req.body.model,
+ };
+ }
+
+ res.json([assistantDocument, updatedAssistant, updatedAction]);
+ } catch (error) {
+ const message = 'Trouble updating the Assistant Action';
+ logger.error(message, error);
+ res.status(500).json({ message });
+ }
+});
+
+/**
+ * Deletes an action for a specific assistant.
+ * @route DELETE /actions/:assistant_id/:action_id
+ * @param {string} req.params.assistant_id - The ID of the assistant.
+ * @param {string} req.params.action_id - The ID of the action to delete.
+ * @returns {Object} 200 - success response - application/json
+ */
+router.delete('/:assistant_id/:action_id/:model', async (req, res) => {
+ try {
+ const { assistant_id, action_id, model } = req.params;
+ req.body = req.body || {}; // Express 5: ensure req.body exists
+ req.body.model = model;
+ const { openai } = await getOpenAIClient({ req, res });
+
+ const initialPromises = [];
+ initialPromises.push(getAssistant({ assistant_id }));
+ initialPromises.push(openai.beta.assistants.retrieve(assistant_id));
+
+ /** @type {[AssistantDocument, Assistant]} */
+ const [assistant_data, assistant] = await Promise.all(initialPromises);
+
+ const { actions = [] } = assistant_data ?? {};
+ const { tools = [] } = assistant ?? {};
+
+ let domain = '';
+ const updatedActions = actions.filter((action) => {
+ if (action.includes(action_id)) {
+ [domain] = action.split(actionDelimiter);
+ return false;
+ }
+ return true;
+ });
+
+ domain = await domainParser(domain, true);
+
+ if (!domain) {
+ return res.status(400).json({ message: 'No domain provided' });
+ }
+
+ const updatedTools = tools.filter(
+ (tool) => !(tool.function && tool.function.name.includes(domain)),
+ );
+
+ await openai.beta.assistants.update(assistant_id, { tools: updatedTools });
+
+ const promises = [];
+ // Only update user field if assistant document doesn't exist
+ const assistantUpdateData = { actions: updatedActions };
+ if (!assistant_data) {
+ assistantUpdateData.user = req.user.id;
+ }
+ promises.push(updateAssistantDoc({ assistant_id }, assistantUpdateData));
+ promises.push(deleteAction({ action_id }));
+
+ await Promise.all(promises);
+ res.status(200).json({ message: 'Action deleted successfully' });
+ } catch (error) {
+ const message = 'Trouble deleting the Assistant Action';
+ logger.error(message, error);
+ res.status(500).json({ message });
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/assistants/chatV1.js b/api/server/routes/assistants/chatV1.js
new file mode 100644
index 0000000000..67bfc007a6
--- /dev/null
+++ b/api/server/routes/assistants/chatV1.js
@@ -0,0 +1,34 @@
+const express = require('express');
+
+const router = express.Router();
+const {
+ setHeaders,
+ handleAbort,
+ validateModel,
+ buildEndpointOption,
+} = require('~/server/middleware');
+const validateConvoAccess = require('~/server/middleware/validate/convoAccess');
+const validateAssistant = require('~/server/middleware/assistants/validate');
+const chatController = require('~/server/controllers/assistants/chatV1');
+
+router.post('/abort', handleAbort());
+
+/**
+ * @route POST /
+ * @desc Chat with an assistant
+ * @access Public
+ * @param {express.Request} req - The request object, containing the request data.
+ * @param {express.Response} res - The response object, used to send back a response.
+ * @returns {void}
+ */
+router.post(
+ '/',
+ validateModel,
+ buildEndpointOption,
+ validateAssistant,
+ validateConvoAccess,
+ setHeaders,
+ chatController,
+);
+
+module.exports = router;
diff --git a/api/server/routes/assistants/chatV2.js b/api/server/routes/assistants/chatV2.js
new file mode 100644
index 0000000000..4612743e47
--- /dev/null
+++ b/api/server/routes/assistants/chatV2.js
@@ -0,0 +1,34 @@
+const express = require('express');
+
+const router = express.Router();
+const {
+ setHeaders,
+ handleAbort,
+ validateModel,
+ buildEndpointOption,
+} = require('~/server/middleware');
+const validateConvoAccess = require('~/server/middleware/validate/convoAccess');
+const validateAssistant = require('~/server/middleware/assistants/validate');
+const chatController = require('~/server/controllers/assistants/chatV2');
+
+router.post('/abort', handleAbort());
+
+/**
+ * @route POST /
+ * @desc Chat with an assistant
+ * @access Public
+ * @param {express.Request} req - The request object, containing the request data.
+ * @param {express.Response} res - The response object, used to send back a response.
+ * @returns {void}
+ */
+router.post(
+ '/',
+ validateModel,
+ buildEndpointOption,
+ validateAssistant,
+ validateConvoAccess,
+ setHeaders,
+ chatController,
+);
+
+module.exports = router;
diff --git a/api/server/routes/assistants/documents.js b/api/server/routes/assistants/documents.js
new file mode 100644
index 0000000000..72a81d8b49
--- /dev/null
+++ b/api/server/routes/assistants/documents.js
@@ -0,0 +1,13 @@
+const express = require('express');
+const controllers = require('~/server/controllers/assistants/v1');
+
+const router = express.Router();
+
+/**
+ * Returns a list of the user's assistant documents (metadata saved to database).
+ * @route GET /assistants/documents
+ * @returns {AssistantDocument[]} 200 - success response - application/json
+ */
+router.get('/', controllers.getAssistantDocuments);
+
+module.exports = router;
diff --git a/api/server/routes/assistants/index.js b/api/server/routes/assistants/index.js
new file mode 100644
index 0000000000..6251f394fa
--- /dev/null
+++ b/api/server/routes/assistants/index.js
@@ -0,0 +1,19 @@
+const express = require('express');
+const { uaParser, checkBan, requireJwtAuth, configMiddleware } = require('~/server/middleware');
+const router = express.Router();
+
+const { v1 } = require('./v1');
+const chatV1 = require('./chatV1');
+const v2 = require('./v2');
+const chatV2 = require('./chatV2');
+
+router.use(requireJwtAuth);
+router.use(checkBan);
+router.use(uaParser);
+router.use(configMiddleware);
+router.use('/v1/', v1);
+router.use('/v1/chat', chatV1);
+router.use('/v2/', v2);
+router.use('/v2/chat', chatV2);
+
+module.exports = router;
diff --git a/api/server/routes/assistants/tools.js b/api/server/routes/assistants/tools.js
new file mode 100644
index 0000000000..324b620958
--- /dev/null
+++ b/api/server/routes/assistants/tools.js
@@ -0,0 +1,8 @@
+const express = require('express');
+const { getAvailableTools } = require('~/server/controllers/PluginController');
+
+const router = express.Router();
+
+router.get('/', getAvailableTools);
+
+module.exports = router;
diff --git a/api/server/routes/assistants/v1.js b/api/server/routes/assistants/v1.js
new file mode 100644
index 0000000000..544a48fb6d
--- /dev/null
+++ b/api/server/routes/assistants/v1.js
@@ -0,0 +1,81 @@
+const express = require('express');
+const controllers = require('~/server/controllers/assistants/v1');
+const documents = require('./documents');
+const actions = require('./actions');
+const tools = require('./tools');
+
+const router = express.Router();
+const avatar = express.Router();
+
+/**
+ * Assistant actions route.
+ * @route GET|POST /assistants/actions
+ */
+router.use('/actions', actions);
+
+/**
+ * Create an assistant.
+ * @route GET /assistants/tools
+ * @returns {TPlugin[]} 200 - application/json
+ */
+router.use('/tools', tools);
+
+/**
+ * Create an assistant.
+ * @route GET /assistants/documents
+ * @returns {AssistantDocument[]} 200 - application/json
+ */
+router.use('/documents', documents);
+
+/**
+ * Create an assistant.
+ * @route POST /assistants
+ * @param {AssistantCreateParams} req.body - The assistant creation parameters.
+ * @returns {Assistant} 201 - success response - application/json
+ */
+router.post('/', controllers.createAssistant);
+
+/**
+ * Retrieves an assistant.
+ * @route GET /assistants/:id
+ * @param {string} req.params.id - Assistant identifier.
+ * @returns {Assistant} 200 - success response - application/json
+ */
+router.get('/:id', controllers.retrieveAssistant);
+
+/**
+ * Modifies an assistant.
+ * @route PATCH /assistants/:id
+ * @param {string} req.params.id - Assistant identifier.
+ * @param {AssistantUpdateParams} req.body - The assistant update parameters.
+ * @returns {Assistant} 200 - success response - application/json
+ */
+router.patch('/:id', controllers.patchAssistant);
+
+/**
+ * Deletes an assistant.
+ * @route DELETE /assistants/:id
+ * @param {string} req.params.id - Assistant identifier.
+ * @returns {Assistant} 200 - success response - application/json
+ */
+router.delete('/:id', controllers.deleteAssistant);
+
+/**
+ * Returns a list of assistants.
+ * @route GET /assistants
+ * @param {AssistantListParams} req.query - The assistant list parameters for pagination and sorting.
+ * @returns {AssistantListResponse} 200 - success response - application/json
+ */
+router.get('/', controllers.listAssistants);
+
+/**
+ * Uploads and updates an avatar for a specific assistant.
+ * @route POST /assistants/:assistant_id/avatar/
+ * @param {string} req.params.assistant_id - The ID of the assistant.
+ * @param {Express.Multer.File} req.file - The avatar image file.
+ * @param {string} [req.body.metadata] - Optional metadata for the assistant's avatar.
+ * @returns {Object} 200 - success response - application/json
+ */
+avatar.post('/:assistant_id/avatar/', controllers.uploadAssistantAvatar);
+
+module.exports = { v1: router, avatar };
diff --git a/api/server/routes/assistants/v2.js b/api/server/routes/assistants/v2.js
new file mode 100644
index 0000000000..3037256078
--- /dev/null
+++ b/api/server/routes/assistants/v2.js
@@ -0,0 +1,83 @@
+const express = require('express');
+const { configMiddleware } = require('~/server/middleware');
+const v1 = require('~/server/controllers/assistants/v1');
+const v2 = require('~/server/controllers/assistants/v2');
+const documents = require('./documents');
+const actions = require('./actions');
+const tools = require('./tools');
+
+const router = express.Router();
+router.use(configMiddleware);
+
+/**
+ * Assistant actions route.
+ * @route GET|POST /assistants/actions
+ */
+router.use('/actions', actions);
+
+/**
+ * Create an assistant.
+ * @route GET /assistants/tools
+ * @returns {TPlugin[]} 200 - application/json
+ */
+router.use('/tools', tools);
+
+/**
+ * Create an assistant.
+ * @route GET /assistants/documents
+ * @returns {AssistantDocument[]} 200 - application/json
+ */
+router.use('/documents', documents);
+
+/**
+ * Create an assistant.
+ * @route POST /assistants
+ * @param {AssistantCreateParams} req.body - The assistant creation parameters.
+ * @returns {Assistant} 201 - success response - application/json
+ */
+router.post('/', v2.createAssistant);
+
+/**
+ * Retrieves an assistant.
+ * @route GET /assistants/:id
+ * @param {string} req.params.id - Assistant identifier.
+ * @returns {Assistant} 200 - success response - application/json
+ */
+router.get('/:id', v1.retrieveAssistant);
+
+/**
+ * Modifies an assistant.
+ * @route PATCH /assistants/:id
+ * @param {string} req.params.id - Assistant identifier.
+ * @param {AssistantUpdateParams} req.body - The assistant update parameters.
+ * @returns {Assistant} 200 - success response - application/json
+ */
+router.patch('/:id', v2.patchAssistant);
+
+/**
+ * Deletes an assistant.
+ * @route DELETE /assistants/:id
+ * @param {string} req.params.id - Assistant identifier.
+ * @returns {Assistant} 200 - success response - application/json
+ */
+router.delete('/:id', v1.deleteAssistant);
+
+/**
+ * Returns a list of assistants.
+ * @route GET /assistants
+ * @param {AssistantListParams} req.query - The assistant list parameters for pagination and sorting.
+ * @returns {AssistantListResponse} 200 - success response - application/json
+ */
+router.get('/', v1.listAssistants);
+
+/**
+ * Uploads and updates an avatar for a specific assistant.
+ * @route POST /avatar/:assistant_id
+ * @param {string} req.params.assistant_id - The ID of the assistant.
+ * @param {Express.Multer.File} req.file - The avatar image file.
+ * @param {string} [req.body.metadata] - Optional metadata for the assistant's avatar.
+ * @returns {Object} 200 - success response - application/json
+ */
+router.post('/avatar/:assistant_id', v1.uploadAssistantAvatar);
+
+module.exports = router;
diff --git a/api/server/routes/auth.js b/api/server/routes/auth.js
new file mode 100644
index 0000000000..e84442f65f
--- /dev/null
+++ b/api/server/routes/auth.js
@@ -0,0 +1,75 @@
+const express = require('express');
+const { createSetBalanceConfig } = require('@librechat/api');
+const {
+ resetPasswordRequestController,
+ resetPasswordController,
+ registrationController,
+ graphTokenController,
+ refreshController,
+} = require('~/server/controllers/AuthController');
+const {
+ regenerateBackupCodes,
+ disable2FA,
+ confirm2FA,
+ enable2FA,
+ verify2FA,
+} = require('~/server/controllers/TwoFactorController');
+const { verify2FAWithTempToken } = require('~/server/controllers/auth/TwoFactorAuthController');
+const { logoutController } = require('~/server/controllers/auth/LogoutController');
+const { loginController } = require('~/server/controllers/auth/LoginController');
+const { getAppConfig } = require('~/server/services/Config');
+const middleware = require('~/server/middleware');
+const { Balance } = require('~/db/models');
+
+const setBalanceConfig = createSetBalanceConfig({
+ getAppConfig,
+ Balance,
+});
+
+const router = express.Router();
+
+const ldapAuth = !!process.env.LDAP_URL && !!process.env.LDAP_USER_SEARCH_BASE;
+//Local
+router.post('/logout', middleware.requireJwtAuth, logoutController);
+router.post(
+ '/login',
+ middleware.logHeaders,
+ middleware.loginLimiter,
+ middleware.checkBan,
+ ldapAuth ? middleware.requireLdapAuth : middleware.requireLocalAuth,
+ setBalanceConfig,
+ loginController,
+);
+router.post('/refresh', refreshController);
+router.post(
+ '/register',
+ middleware.registerLimiter,
+ middleware.checkBan,
+ middleware.checkInviteUser,
+ middleware.validateRegistration,
+ registrationController,
+);
+router.post(
+ '/requestPasswordReset',
+ middleware.resetPasswordLimiter,
+ middleware.checkBan,
+ middleware.validatePasswordReset,
+ resetPasswordRequestController,
+);
+router.post(
+ '/resetPassword',
+ middleware.checkBan,
+ middleware.validatePasswordReset,
+ resetPasswordController,
+);
+
+router.get('/2fa/enable', middleware.requireJwtAuth, enable2FA);
+router.post('/2fa/verify', middleware.requireJwtAuth, verify2FA);
+router.post('/2fa/verify-temp', middleware.checkBan, verify2FAWithTempToken);
+router.post('/2fa/confirm', middleware.requireJwtAuth, confirm2FA);
+router.post('/2fa/disable', middleware.requireJwtAuth, disable2FA);
+router.post('/2fa/backup/regenerate', middleware.requireJwtAuth, regenerateBackupCodes);
+
+router.get('/graph-token', middleware.requireJwtAuth, graphTokenController);
+
+module.exports = router;
diff --git a/api/server/routes/balance.js b/api/server/routes/balance.js
new file mode 100644
index 0000000000..87d8428880
--- /dev/null
+++ b/api/server/routes/balance.js
@@ -0,0 +1,8 @@
+const express = require('express');
+const router = express.Router();
+const controller = require('../controllers/Balance');
+const { requireJwtAuth } = require('../middleware/');
+
+router.get('/', requireJwtAuth, controller);
+
+module.exports = router;
diff --git a/api/server/routes/banner.js b/api/server/routes/banner.js
new file mode 100644
index 0000000000..cf7eafd017
--- /dev/null
+++ b/api/server/routes/banner.js
@@ -0,0 +1,15 @@
+const express = require('express');
+
+const { getBanner } = require('~/models/Banner');
+const optionalJwtAuth = require('~/server/middleware/optionalJwtAuth');
+const router = express.Router();
+
+router.get('/', optionalJwtAuth, async (req, res) => {
+ try {
+ res.status(200).send(await getBanner(req.user));
+ } catch (error) {
+ res.status(500).json({ message: 'Error getting banner' });
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/categories.js b/api/server/routes/categories.js
new file mode 100644
index 0000000000..da1828b3ce
--- /dev/null
+++ b/api/server/routes/categories.js
@@ -0,0 +1,15 @@
+const express = require('express');
+const router = express.Router();
+const { requireJwtAuth } = require('~/server/middleware');
+const { getCategories } = require('~/models/Categories');
+
+router.get('/', requireJwtAuth, async (req, res) => {
+ try {
+ const categories = await getCategories();
+ res.status(200).send(categories);
+ } catch (error) {
+ res.status(500).send({ message: 'Failed to retrieve categories', error: error.message });
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/config.js b/api/server/routes/config.js
new file mode 100644
index 0000000000..a2dc5b79d2
--- /dev/null
+++ b/api/server/routes/config.js
@@ -0,0 +1,158 @@
+const express = require('express');
+const { logger } = require('@librechat/data-schemas');
+const { isEnabled, getBalanceConfig } = require('@librechat/api');
+const { Constants, CacheKeys, defaultSocialLogins } = require('librechat-data-provider');
+const { getLdapConfig } = require('~/server/services/Config/ldap');
+const { getAppConfig } = require('~/server/services/Config/app');
+const { getProjectByName } = require('~/models/Project');
+const { getLogStores } = require('~/cache');
+
+const router = express.Router();
+const emailLoginEnabled =
+ process.env.ALLOW_EMAIL_LOGIN === undefined || isEnabled(process.env.ALLOW_EMAIL_LOGIN);
+const passwordResetEnabled = isEnabled(process.env.ALLOW_PASSWORD_RESET);
+
+const sharedLinksEnabled =
+ process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
+
+const publicSharedLinksEnabled =
+ sharedLinksEnabled &&
+ (process.env.ALLOW_SHARED_LINKS_PUBLIC === undefined ||
+ isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC));
+
+const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
+const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);
+
+router.get('/', async function (req, res) {
+ const cache = getLogStores(CacheKeys.CONFIG_STORE);
+
+ const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG);
+ if (cachedStartupConfig) {
+ res.send(cachedStartupConfig);
+ return;
+ }
+
+ const isBirthday = () => {
+ const today = new Date();
+ return today.getMonth() === 1 && today.getDate() === 11;
+ };
+
+ const instanceProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, '_id');
+
+ const ldap = getLdapConfig();
+
+ try {
+ const appConfig = await getAppConfig({ role: req.user?.role });
+
+ const isOpenIdEnabled =
+ !!process.env.OPENID_CLIENT_ID &&
+ !!process.env.OPENID_CLIENT_SECRET &&
+ !!process.env.OPENID_ISSUER &&
+ !!process.env.OPENID_SESSION_SECRET;
+
+ const isSamlEnabled =
+ !!process.env.SAML_ENTRY_POINT &&
+ !!process.env.SAML_ISSUER &&
+ !!process.env.SAML_CERT &&
+ !!process.env.SAML_SESSION_SECRET;
+
+ const balanceConfig = getBalanceConfig(appConfig);
+
+ /** @type {TStartupConfig} */
+ const payload = {
+ appTitle: process.env.APP_TITLE || 'LibreChat',
+ socialLogins: appConfig?.registration?.socialLogins ?? defaultSocialLogins,
+ discordLoginEnabled: !!process.env.DISCORD_CLIENT_ID && !!process.env.DISCORD_CLIENT_SECRET,
+ facebookLoginEnabled:
+ !!process.env.FACEBOOK_CLIENT_ID && !!process.env.FACEBOOK_CLIENT_SECRET,
+ githubLoginEnabled: !!process.env.GITHUB_CLIENT_ID && !!process.env.GITHUB_CLIENT_SECRET,
+ googleLoginEnabled: !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET,
+ appleLoginEnabled:
+ !!process.env.APPLE_CLIENT_ID &&
+ !!process.env.APPLE_TEAM_ID &&
+ !!process.env.APPLE_KEY_ID &&
+ !!process.env.APPLE_PRIVATE_KEY_PATH,
+ openidLoginEnabled: isOpenIdEnabled,
+ openidLabel: process.env.OPENID_BUTTON_LABEL || 'Continue with OpenID',
+ openidImageUrl: process.env.OPENID_IMAGE_URL,
+ openidAutoRedirect: isEnabled(process.env.OPENID_AUTO_REDIRECT),
+ samlLoginEnabled: !isOpenIdEnabled && isSamlEnabled,
+ samlLabel: process.env.SAML_BUTTON_LABEL,
+ samlImageUrl: process.env.SAML_IMAGE_URL,
+ serverDomain: process.env.DOMAIN_SERVER || 'http://localhost:3080',
+ emailLoginEnabled,
+ registrationEnabled: !ldap?.enabled && isEnabled(process.env.ALLOW_REGISTRATION),
+ socialLoginEnabled: isEnabled(process.env.ALLOW_SOCIAL_LOGIN),
+ emailEnabled:
+ (!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
+ !!process.env.EMAIL_USERNAME &&
+ !!process.env.EMAIL_PASSWORD &&
+ !!process.env.EMAIL_FROM,
+ passwordResetEnabled,
+ showBirthdayIcon:
+ isBirthday() ||
+ isEnabled(process.env.SHOW_BIRTHDAY_ICON) ||
+ process.env.SHOW_BIRTHDAY_ICON === '',
+ helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai',
+ interface: appConfig?.interfaceConfig,
+ turnstile: appConfig?.turnstileConfig,
+ modelSpecs: appConfig?.modelSpecs,
+ balance: balanceConfig,
+ sharedLinksEnabled,
+ publicSharedLinksEnabled,
+ analyticsGtmId: process.env.ANALYTICS_GTM_ID,
+ instanceProjectId: instanceProject._id.toString(),
+ bundlerURL: process.env.SANDPACK_BUNDLER_URL,
+ staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL,
+ sharePointFilePickerEnabled,
+ sharePointBaseUrl: process.env.SHAREPOINT_BASE_URL,
+ sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE,
+ sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE,
+ openidReuseTokens,
+ conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES
+ ? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
+ : 0,
+ };
+
+ const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10);
+ if (minPasswordLength && !isNaN(minPasswordLength)) {
+ payload.minPasswordLength = minPasswordLength;
+ }
+
+ const webSearchConfig = appConfig?.webSearch;
+ if (
+ webSearchConfig != null &&
+ (webSearchConfig.searchProvider ||
+ webSearchConfig.scraperProvider ||
+ webSearchConfig.rerankerType)
+ ) {
+ payload.webSearch = {};
+ }
+
+ if (webSearchConfig?.searchProvider) {
+ payload.webSearch.searchProvider = webSearchConfig.searchProvider;
+ }
+ if (webSearchConfig?.scraperProvider) {
+ payload.webSearch.scraperProvider = webSearchConfig.scraperProvider;
+ }
+ if (webSearchConfig?.rerankerType) {
+ payload.webSearch.rerankerType = webSearchConfig.rerankerType;
+ }
+
+ if (ldap) {
+ payload.ldap = ldap;
+ }
+
+ if (typeof process.env.CUSTOM_FOOTER === 'string') {
+ payload.customFooter = process.env.CUSTOM_FOOTER;
+ }
+
+ await cache.set(CacheKeys.STARTUP_CONFIG, payload);
+ return res.status(200).send(payload);
+ } catch (err) {
+ logger.error('Error in startup config', err);
+ return res.status(500).send({ error: err.message });
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js
index 4b9320873f..ad82ede10a 100644
--- a/api/server/routes/convos.js
+++ b/api/server/routes/convos.js
@@ -1,37 +1,243 @@
+const multer = require('multer');
const express = require('express');
+const { sleep } = require('@librechat/agents');
+const { isEnabled } = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
+const {
+ createImportLimiters,
+ createForkLimiters,
+ configMiddleware,
+} = require('~/server/middleware');
+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 { deleteAllSharedLinks, deleteConvoSharedLink } = require('~/models');
+const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
+const { importConversations } = require('~/server/utils/import');
+const { deleteToolCalls } = require('~/models/ToolCall');
+const getLogStores = require('~/cache/getLogStores');
+
+const assistantClients = {
+ [EModelEndpoint.azureAssistants]: require('~/server/services/Endpoints/azureAssistants'),
+ [EModelEndpoint.assistants]: require('~/server/services/Endpoints/assistants'),
+};
+
const router = express.Router();
-const { getConvos, deleteConvos, updateConvo } = require('../../models/Conversation');
+router.use(requireJwtAuth);
router.get('/', async (req, res) => {
- const pageNumber = req.query.pageNumber || 1;
- res.status(200).send(await getConvos(pageNumber));
-});
+ 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 sortBy = req.query.sortBy || 'createdAt';
+ const sortDirection = req.query.sortDirection || 'desc';
-router.post('/clear', async (req, res) => {
- let filter = {};
- const { conversationId } = req.body.arg;
- if (conversationId) {
- filter = { conversationId };
+ let tags;
+ if (req.query.tags) {
+ tags = Array.isArray(req.query.tags) ? req.query.tags : [req.query.tags];
}
try {
- const dbResponse = await deleteConvos(filter);
- res.status(201).send(dbResponse);
+ const result = await getConvosByCursor(req.user.id, {
+ cursor,
+ limit,
+ isArchived,
+ tags,
+ search,
+ sortBy,
+ sortDirection,
+ });
+ res.status(200).json(result);
} catch (error) {
- console.error(error);
- res.status(500).send(error);
+ logger.error('Error fetching conversations', error);
+ res.status(500).json({ error: 'Error fetching conversations' });
+ }
+});
+
+router.get('/:conversationId', async (req, res) => {
+ const { conversationId } = req.params;
+ const convo = await getConvo(req.user.id, conversationId);
+
+ if (convo) {
+ res.status(200).json(convo);
+ } else {
+ res.status(404).end();
+ }
+});
+
+router.post('/gen_title', async (req, res) => {
+ const { conversationId } = req.body;
+ const titleCache = getLogStores(CacheKeys.GEN_TITLE);
+ const key = `${req.user.id}-${conversationId}`;
+ let title = await titleCache.get(key);
+
+ if (!title) {
+ // Retry every 1s for up to 20s
+ for (let i = 0; i < 20; i++) {
+ await sleep(1000);
+ title = await titleCache.get(key);
+ if (title) {
+ break;
+ }
+ }
+ }
+
+ if (title) {
+ await titleCache.delete(key);
+ res.status(200).json({ title });
+ } else {
+ res.status(404).json({
+ message: "Title not found or method not implemented for the conversation's endpoint",
+ });
+ }
+});
+
+router.delete('/', async (req, res) => {
+ let filter = {};
+ const { conversationId, source, thread_id, endpoint } = req.body.arg;
+
+ // Prevent deletion of all conversations
+ if (!conversationId && !source && !thread_id && !endpoint) {
+ return res.status(400).json({
+ error: 'no parameters provided',
+ });
+ }
+
+ if (conversationId) {
+ filter = { conversationId };
+ } else if (source === 'button') {
+ return res.status(200).send('No conversationId provided');
+ }
+
+ if (
+ typeof endpoint !== 'undefined' &&
+ Object.prototype.propertyIsEnumerable.call(assistantClients, endpoint)
+ ) {
+ /** @type {{ openai: OpenAI }} */
+ const { openai } = await assistantClients[endpoint].initializeClient({ req, res });
+ try {
+ const response = await openai.beta.threads.delete(thread_id);
+ logger.debug('Deleted OpenAI thread:', response);
+ } catch (error) {
+ logger.error('Error deleting OpenAI thread:', error);
+ }
+ }
+
+ try {
+ const dbResponse = await deleteConvos(req.user.id, filter);
+ if (filter.conversationId) {
+ await deleteToolCalls(req.user.id, filter.conversationId);
+ await deleteConvoSharedLink(req.user.id, filter.conversationId);
+ }
+ res.status(201).json(dbResponse);
+ } catch (error) {
+ logger.error('Error clearing conversations', error);
+ res.status(500).send('Error clearing conversations');
+ }
+});
+
+router.delete('/all', async (req, res) => {
+ try {
+ const dbResponse = await deleteConvos(req.user.id, {});
+ await deleteToolCalls(req.user.id);
+ await deleteAllSharedLinks(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;
+ if (!update.conversationId) {
+ return res.status(400).json({ error: 'conversationId is required' });
+ }
+
try {
- const dbResponse = await updateConvo(update);
- res.status(201).send(dbResponse);
+ const dbResponse = await saveConvo(req, update, {
+ context: `POST /api/convos/update ${update.conversationId}`,
+ });
+ res.status(201).json(dbResponse);
} catch (error) {
- console.error(error);
- res.status(500).send(error);
+ logger.error('Error updating conversation', error);
+ res.status(500).send('Error updating conversation');
+ }
+});
+
+const { importIpLimiter, importUserLimiter } = createImportLimiters();
+const { forkIpLimiter, forkUserLimiter } = createForkLimiters();
+const upload = multer({ storage: storage, fileFilter: importFileFilter });
+
+/**
+ * Imports a conversation from a JSON file and saves it to the database.
+ * @route POST /import
+ * @param {Express.Multer.File} req.file - The JSON file to import.
+ * @returns {object} 201 - success response - application/json
+ */
+router.post(
+ '/import',
+ importIpLimiter,
+ importUserLimiter,
+ configMiddleware,
+ upload.single('file'),
+ async (req, res) => {
+ try {
+ /* TODO: optimize to return imported conversations and add manually */
+ await importConversations({ filepath: req.file.path, requestUserId: req.user.id });
+ res.status(201).json({ message: 'Conversation(s) imported successfully' });
+ } catch (error) {
+ logger.error('Error processing file', error);
+ res.status(500).send('Error processing file');
+ }
+ },
+);
+
+/**
+ * POST /fork
+ * This route handles forking a conversation based on the TForkConvoRequest and responds with TForkConvoResponse.
+ * @route POST /fork
+ * @param {express.Request<{}, TForkConvoResponse, TForkConvoRequest>} req - Express request object.
+ * @param {express.Response} res - Express response object.
+ * @returns {Promise} - The response after forking the conversation.
+ */
+router.post('/fork', forkIpLimiter, forkUserLimiter, async (req, res) => {
+ try {
+ /** @type {TForkConvoRequest} */
+ const { conversationId, messageId, option, splitAtTarget, latestMessageId } = req.body;
+ const result = await forkConversation({
+ requestUserId: req.user.id,
+ originalConvoId: conversationId,
+ targetMessageId: messageId,
+ latestMessageId,
+ records: true,
+ splitAtTarget,
+ option,
+ });
+
+ res.json(result);
+ } catch (error) {
+ logger.error('Error forking conversation:', error);
+ res.status(500).send('Error forking conversation');
+ }
+});
+
+router.post('/duplicate', async (req, res) => {
+ const { conversationId, title } = req.body;
+
+ try {
+ const result = await duplicateConversation({
+ userId: req.user.id,
+ conversationId,
+ title,
+ });
+ res.status(201).json(result);
+ } catch (error) {
+ logger.error('Error duplicating conversation:', error);
+ res.status(500).send('Error duplicating conversation');
}
});
diff --git a/api/server/routes/customGpts.js b/api/server/routes/customGpts.js
deleted file mode 100644
index a27b640842..0000000000
--- a/api/server/routes/customGpts.js
+++ /dev/null
@@ -1,56 +0,0 @@
-const express = require('express');
-const router = express.Router();
-const { getCustomGpts, updateCustomGpt, updateByLabel, deleteCustomGpts } = require('../../models');
-
-router.get('/', async (req, res) => {
- const models = (await getCustomGpts()).map(model => {
- model = model.toObject();
- model._id = model._id.toString();
- return model;
- });
- res.status(200).send(models);
-});
-
-router.post('/delete', async (req, res) => {
- const { arg } = req.body;
-
- try {
- const dbResponse = await deleteCustomGpts(arg);
- res.status(201).send(dbResponse);
- } catch (error) {
- console.error(error);
- res.status(500).send(error);
- }
-});
-
-// router.post('/create', async (req, res) => {
-// const payload = req.body.arg;
-
-// try {
-// const dbResponse = await createCustomGpt(payload);
-// res.status(201).send(dbResponse);
-// } catch (error) {
-// console.error(error);
-// res.status(500).send(error);
-// }
-// });
-
-router.post('/', async (req, res) => {
- const update = req.body.arg;
-
- let setter = updateCustomGpt;
-
- if (update.prevLabel) {
- setter = updateByLabel;
- }
-
- try {
- const dbResponse = await setter(update);
- res.status(201).send(dbResponse);
- } catch (error) {
- console.error(error);
- res.status(500).send(error);
- }
-});
-
-module.exports = router;
diff --git a/api/server/routes/endpoints.js b/api/server/routes/endpoints.js
new file mode 100644
index 0000000000..794abde0c2
--- /dev/null
+++ b/api/server/routes/endpoints.js
@@ -0,0 +1,7 @@
+const express = require('express');
+const endpointController = require('~/server/controllers/EndpointController');
+
+const router = express.Router();
+router.get('/', endpointController);
+
+module.exports = router;
diff --git a/api/server/routes/files/avatar.js b/api/server/routes/files/avatar.js
new file mode 100644
index 0000000000..f5c9379178
--- /dev/null
+++ b/api/server/routes/files/avatar.js
@@ -0,0 +1,49 @@
+const fs = require('fs').promises;
+const express = require('express');
+const { logger } = require('@librechat/data-schemas');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+const { resizeAvatar } = require('~/server/services/Files/images/avatar');
+const { getFileStrategy } = require('~/server/utils/getFileStrategy');
+const { filterFile } = require('~/server/services/Files/process');
+
+const router = express.Router();
+
+router.post('/', async (req, res) => {
+ try {
+ const appConfig = req.config;
+ filterFile({ req, file: req.file, image: true, isAvatar: true });
+ const userId = req.user.id;
+ const { manual } = req.body;
+ const input = await fs.readFile(req.file.path);
+
+ if (!userId) {
+ throw new Error('User ID is undefined');
+ }
+
+ const fileStrategy = getFileStrategy(appConfig, { isAvatar: true });
+ const desiredFormat = appConfig.imageOutputType;
+ const resizedBuffer = await resizeAvatar({
+ userId,
+ input,
+ desiredFormat,
+ });
+
+ const { processAvatar } = getStrategyFunctions(fileStrategy);
+ const url = await processAvatar({ buffer: resizedBuffer, userId, manual });
+
+ res.json({ url });
+ } catch (error) {
+ const message = 'An error occurred while uploading the profile picture';
+ logger.error(message, error);
+ res.status(500).json({ message });
+ } finally {
+ try {
+ await fs.unlink(req.file.path);
+ logger.debug('[/files/images/avatar] Temp. image upload file deleted');
+ } catch {
+ logger.debug('[/files/images/avatar] Temp. image upload file already deleted');
+ }
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/files/files.agents.test.js b/api/server/routes/files/files.agents.test.js
new file mode 100644
index 0000000000..8d0d640f2e
--- /dev/null
+++ b/api/server/routes/files/files.agents.test.js
@@ -0,0 +1,342 @@
+const express = require('express');
+const request = require('supertest');
+const mongoose = require('mongoose');
+const { v4: uuidv4 } = require('uuid');
+const { createMethods } = require('@librechat/data-schemas');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { AccessRoleIds, ResourceType, PrincipalType } = require('librechat-data-provider');
+const { createAgent } = require('~/models/Agent');
+const { createFile } = require('~/models');
+
+// Only mock the external dependencies that we don't want to test
+jest.mock('~/server/services/Files/process', () => ({
+ processDeleteRequest: jest.fn().mockResolvedValue({}),
+ filterFile: jest.fn(),
+ processFileUpload: jest.fn(),
+ processAgentFileUpload: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/strategies', () => ({
+ getStrategyFunctions: jest.fn(() => ({})),
+}));
+
+jest.mock('~/server/controllers/assistants/helpers', () => ({
+ getOpenAIClient: jest.fn(),
+}));
+
+jest.mock('~/server/services/Tools/credentials', () => ({
+ loadAuthValues: jest.fn(),
+}));
+
+// Import the router
+const router = require('~/server/routes/files/files');
+
+describe('File Routes - Agent Files Endpoint', () => {
+ let app;
+ let mongoServer;
+ let authorId;
+ let otherUserId;
+ let agentId;
+ let fileId1;
+ let fileId2;
+ let fileId3;
+ let File;
+ let User;
+ let Agent;
+ let methods;
+ let AclEntry;
+ // eslint-disable-next-line no-unused-vars
+ let AccessRole;
+ let modelsToCleanup = [];
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+
+ // Initialize all models using createModels
+ const { createModels } = require('@librechat/data-schemas');
+ const models = createModels(mongoose);
+
+ // Track which models we're adding
+ modelsToCleanup = Object.keys(models);
+
+ // Register models on mongoose.models so methods can access them
+ Object.assign(mongoose.models, models);
+
+ // Create methods with our test mongoose instance
+ methods = createMethods(mongoose);
+
+ // Now we can access models from the db/models
+ File = models.File;
+ Agent = models.Agent;
+ AclEntry = models.AclEntry;
+ User = models.User;
+ AccessRole = models.AccessRole;
+
+ // Seed default roles using our methods
+ await methods.seedDefaultRoles();
+
+ app = express();
+ app.use(express.json());
+
+ // Mock authentication middleware
+ app.use((req, res, next) => {
+ req.user = { id: otherUserId || 'default-user' };
+ req.app = { locals: {} };
+ next();
+ });
+
+ app.use('/files', router);
+ });
+
+ afterAll(async () => {
+ // Clean up all collections before disconnecting
+ const collections = mongoose.connection.collections;
+ for (const key in collections) {
+ await collections[key].deleteMany({});
+ }
+
+ // Clear only the models we added
+ for (const modelName of modelsToCleanup) {
+ if (mongoose.models[modelName]) {
+ delete mongoose.models[modelName];
+ }
+ }
+
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ // Clean up all test data
+ await File.deleteMany({});
+ await Agent.deleteMany({});
+ await User.deleteMany({});
+ await AclEntry.deleteMany({});
+ // Don't delete AccessRole as they are seeded defaults needed for tests
+
+ // Create test users
+ authorId = new mongoose.Types.ObjectId();
+ otherUserId = new mongoose.Types.ObjectId();
+ agentId = uuidv4();
+ fileId1 = uuidv4();
+ fileId2 = uuidv4();
+ fileId3 = uuidv4();
+
+ // Create users in database
+ await User.create({
+ _id: authorId,
+ username: 'author',
+ email: 'author@test.com',
+ });
+
+ await User.create({
+ _id: otherUserId,
+ username: 'other',
+ email: 'other@test.com',
+ });
+
+ // Create files
+ await createFile({
+ user: authorId,
+ file_id: fileId1,
+ filename: 'file1.txt',
+ filepath: '/uploads/file1.txt',
+ bytes: 100,
+ type: 'text/plain',
+ });
+
+ await createFile({
+ user: authorId,
+ file_id: fileId2,
+ filename: 'file2.txt',
+ filepath: '/uploads/file2.txt',
+ bytes: 200,
+ type: 'text/plain',
+ });
+
+ await createFile({
+ user: otherUserId,
+ file_id: fileId3,
+ filename: 'file3.txt',
+ filepath: '/uploads/file3.txt',
+ bytes: 300,
+ type: 'text/plain',
+ });
+ });
+
+ describe('GET /files/agent/:agent_id', () => {
+ it('should return files accessible through the agent for non-author with EDIT permission', async () => {
+ // Create an agent with files attached
+ const agent = await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: [fileId1, fileId2],
+ },
+ },
+ });
+
+ // Grant EDIT permission to user on the agent using PermissionService
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_EDITOR,
+ grantedBy: authorId,
+ });
+
+ // Mock req.user for this request
+ app.use((req, res, next) => {
+ req.user = { id: otherUserId.toString() };
+ next();
+ });
+
+ const response = await request(app).get(`/files/agent/${agentId}`);
+
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body).toHaveLength(2);
+ expect(response.body.map((f) => f.file_id)).toContain(fileId1);
+ expect(response.body.map((f) => f.file_id)).toContain(fileId2);
+ });
+
+ it('should return 400 when agent_id is not provided', async () => {
+ const response = await request(app).get('/files/agent/');
+
+ expect(response.status).toBe(404); // Express returns 404 for missing route parameter
+ });
+
+ it('should return empty array for non-existent agent', async () => {
+ const response = await request(app).get('/files/agent/non-existent-agent');
+
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body).toEqual([]);
+ });
+
+ it('should return empty array when user only has VIEW permission', async () => {
+ // Create an agent with files attached
+ const agent = await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: [fileId1, fileId2],
+ },
+ },
+ });
+
+ // Grant only VIEW permission to user on the agent
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_VIEWER,
+ grantedBy: authorId,
+ });
+
+ const response = await request(app).get(`/files/agent/${agentId}`);
+
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body).toEqual([]);
+ });
+
+ it('should return agent files for agent author', async () => {
+ // Create an agent with files attached
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: [fileId1, fileId2],
+ },
+ },
+ });
+
+ // Create a new app instance with author authentication
+ const authorApp = express();
+ authorApp.use(express.json());
+ authorApp.use((req, res, next) => {
+ req.user = { id: authorId.toString() };
+ req.app = { locals: {} };
+ next();
+ });
+ authorApp.use('/files', router);
+
+ const response = await request(authorApp).get(`/files/agent/${agentId}`);
+
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body).toHaveLength(2);
+ });
+
+ it('should return files uploaded by other users to shared agent for author', async () => {
+ const anotherUserId = new mongoose.Types.ObjectId();
+ const otherUserFileId = uuidv4();
+
+ await User.create({
+ _id: anotherUserId,
+ username: 'another',
+ email: 'another@test.com',
+ });
+
+ await createFile({
+ user: anotherUserId,
+ file_id: otherUserFileId,
+ filename: 'other-user-file.txt',
+ filepath: '/uploads/other-user-file.txt',
+ bytes: 400,
+ type: 'text/plain',
+ });
+
+ // Create agent to include the file uploaded by another user
+ await createAgent({
+ id: agentId,
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: [fileId1, otherUserFileId],
+ },
+ },
+ });
+
+ // Create a new app instance with author authentication
+ const authorApp = express();
+ authorApp.use(express.json());
+ authorApp.use((req, res, next) => {
+ req.user = { id: authorId.toString() };
+ req.app = { locals: {} };
+ next();
+ });
+ authorApp.use('/files', router);
+
+ const response = await request(authorApp).get(`/files/agent/${agentId}`);
+
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body).toHaveLength(2);
+ expect(response.body.map((f) => f.file_id)).toContain(fileId1);
+ expect(response.body.map((f) => f.file_id)).toContain(otherUserFileId);
+ });
+ });
+});
diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js
new file mode 100644
index 0000000000..de149320bf
--- /dev/null
+++ b/api/server/routes/files/files.js
@@ -0,0 +1,420 @@
+const fs = require('fs').promises;
+const express = require('express');
+const { EnvVar } = require('@librechat/agents');
+const { logger } = require('@librechat/data-schemas');
+const {
+ Time,
+ isUUID,
+ CacheKeys,
+ FileSources,
+ ResourceType,
+ EModelEndpoint,
+ PermissionBits,
+ checkOpenAIStorage,
+ isAssistantsEndpoint,
+} = require('librechat-data-provider');
+const {
+ filterFile,
+ processFileUpload,
+ processDeleteRequest,
+ processAgentFileUpload,
+} = require('~/server/services/Files/process');
+const { fileAccess } = require('~/server/middleware/accessResources/fileAccess');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
+const { checkPermission } = require('~/server/services/PermissionService');
+const { loadAuthValues } = require('~/server/services/Tools/credentials');
+const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud');
+const { hasAccessToFilesViaAgent } = require('~/server/services/Files');
+const { getFiles, batchUpdateFiles } = require('~/models');
+const { cleanFileName } = require('~/server/utils/files');
+const { getAssistant } = require('~/models/Assistant');
+const { getAgent } = require('~/models/Agent');
+const { getLogStores } = require('~/cache');
+const { Readable } = require('stream');
+
+const router = express.Router();
+
+router.get('/', async (req, res) => {
+ try {
+ const appConfig = req.config;
+ const files = await getFiles({ user: req.user.id });
+ if (appConfig.fileStrategy === FileSources.s3) {
+ try {
+ const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
+ const alreadyChecked = await cache.get(req.user.id);
+ if (!alreadyChecked) {
+ await refreshS3FileUrls(files, batchUpdateFiles);
+ await cache.set(req.user.id, true, Time.THIRTY_MINUTES);
+ }
+ } catch (error) {
+ logger.warn('[/files] Error refreshing S3 file URLs:', error);
+ }
+ }
+ res.status(200).send(files);
+ } catch (error) {
+ logger.error('[/files] Error getting files:', error);
+ res.status(400).json({ message: 'Error in request', error: error.message });
+ }
+});
+
+/**
+ * Get files specific to an agent
+ * @route GET /files/agent/:agent_id
+ * @param {string} agent_id - The agent ID to get files for
+ * @returns {Promise} Array of files attached to the agent
+ */
+router.get('/agent/:agent_id', async (req, res) => {
+ try {
+ const { agent_id } = req.params;
+ const userId = req.user.id;
+
+ if (!agent_id) {
+ return res.status(400).json({ error: 'Agent ID is required' });
+ }
+
+ const agent = await getAgent({ id: agent_id });
+ if (!agent) {
+ return res.status(200).json([]);
+ }
+
+ if (agent.author.toString() !== userId) {
+ const hasEditPermission = await checkPermission({
+ userId,
+ role: req.user.role,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ requiredPermission: PermissionBits.EDIT,
+ });
+
+ if (!hasEditPermission) {
+ return res.status(200).json([]);
+ }
+ }
+
+ const agentFileIds = [];
+ if (agent.tool_resources) {
+ for (const [, resource] of Object.entries(agent.tool_resources)) {
+ if (resource?.file_ids && Array.isArray(resource.file_ids)) {
+ agentFileIds.push(...resource.file_ids);
+ }
+ }
+ }
+
+ if (agentFileIds.length === 0) {
+ return res.status(200).json([]);
+ }
+
+ const files = await getFiles({ file_id: { $in: agentFileIds } }, null, { text: 0 });
+
+ res.status(200).json(files);
+ } catch (error) {
+ logger.error('[/files/agent/:agent_id] Error fetching agent files:', error);
+ res.status(500).json({ error: 'Failed to fetch agent files' });
+ }
+});
+
+router.get('/config', async (req, res) => {
+ try {
+ const appConfig = req.config;
+ res.status(200).json(appConfig.fileConfig);
+ } catch (error) {
+ logger.error('[/files] Error getting fileConfig', error);
+ res.status(400).json({ message: 'Error in request', error: error.message });
+ }
+});
+
+router.delete('/', async (req, res) => {
+ try {
+ const { files: _files } = req.body;
+
+ /** @type {MongoFile[]} */
+ const files = _files.filter((file) => {
+ if (!file.file_id) {
+ return false;
+ }
+ if (!file.filepath) {
+ return false;
+ }
+
+ if (/^(file|assistant)-/.test(file.file_id)) {
+ return true;
+ }
+
+ return isUUID.safeParse(file.file_id).success;
+ });
+
+ if (files.length === 0) {
+ res.status(204).json({ message: 'Nothing provided to delete' });
+ return;
+ }
+
+ const fileIds = files.map((file) => file.file_id);
+ const dbFiles = await getFiles({ file_id: { $in: fileIds } });
+
+ const ownedFiles = [];
+ const nonOwnedFiles = [];
+
+ for (const file of dbFiles) {
+ if (file.user.toString() === req.user.id.toString()) {
+ ownedFiles.push(file);
+ } else {
+ nonOwnedFiles.push(file);
+ }
+ }
+
+ if (nonOwnedFiles.length === 0) {
+ await processDeleteRequest({ req, files: ownedFiles });
+ logger.debug(
+ `[/files] Files deleted successfully: ${ownedFiles
+ .filter((f) => f.file_id)
+ .map((f) => f.file_id)
+ .join(', ')}`,
+ );
+ res.status(200).json({ message: 'Files deleted successfully' });
+ return;
+ }
+
+ let authorizedFiles = [...ownedFiles];
+ let unauthorizedFiles = [];
+
+ if (req.body.agent_id && nonOwnedFiles.length > 0) {
+ const nonOwnedFileIds = nonOwnedFiles.map((f) => f.file_id);
+ const accessMap = await hasAccessToFilesViaAgent({
+ userId: req.user.id,
+ role: req.user.role,
+ fileIds: nonOwnedFileIds,
+ agentId: req.body.agent_id,
+ isDelete: true,
+ });
+
+ for (const file of nonOwnedFiles) {
+ if (accessMap.get(file.file_id)) {
+ authorizedFiles.push(file);
+ } else {
+ unauthorizedFiles.push(file);
+ }
+ }
+ } else {
+ unauthorizedFiles = nonOwnedFiles;
+ }
+
+ if (unauthorizedFiles.length > 0) {
+ return res.status(403).json({
+ message: 'You can only delete files you have access to',
+ unauthorizedFiles: unauthorizedFiles.map((f) => f.file_id),
+ });
+ }
+
+ /* 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,
+ });
+
+ const toolResourceFiles = agent.tool_resources?.[req.body.tool_resource]?.file_ids ?? [];
+ const agentFiles = files.filter((f) => toolResourceFiles.includes(f.file_id));
+
+ await processDeleteRequest({ req, files: agentFiles });
+ 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;
+ } else if (
+ req.body.assistant_id &&
+ req.body.files?.[0]?.filepath === EModelEndpoint.azureAssistants
+ ) {
+ await processDeleteRequest({ req, files: req.body.files });
+ return res
+ .status(200)
+ .json({ message: 'File associations removed successfully from Azure Assistant' });
+ }
+
+ await processDeleteRequest({ req, files: authorizedFiles });
+
+ logger.debug(
+ `[/files] Files deleted successfully: ${authorizedFiles
+ .filter((f) => f.file_id)
+ .map((f) => f.file_id)
+ .join(', ')}`,
+ );
+ res.status(200).json({ message: 'Files deleted successfully' });
+ } catch (error) {
+ logger.error('[/files] Error deleting files:', error);
+ res.status(400).json({ message: 'Error in request', error: error.message });
+ }
+});
+
+function isValidID(str) {
+ return /^[A-Za-z0-9_-]{21}$/.test(str);
+}
+
+router.get('/code/download/:session_id/:fileId', async (req, res) => {
+ try {
+ const { session_id, fileId } = req.params;
+ const logPrefix = `Session ID: ${session_id} | File ID: ${fileId} | Code output download requested by user `;
+ logger.debug(logPrefix);
+
+ if (!session_id || !fileId) {
+ return res.status(400).send('Bad request');
+ }
+
+ if (!isValidID(session_id) || !isValidID(fileId)) {
+ logger.debug(`${logPrefix} invalid session_id or fileId`);
+ return res.status(400).send('Bad request');
+ }
+
+ const { getDownloadStream } = getStrategyFunctions(FileSources.execute_code);
+ if (!getDownloadStream) {
+ logger.warn(
+ `${logPrefix} has no stream method implemented for ${FileSources.execute_code} source`,
+ );
+ return res.status(501).send('Not Implemented');
+ }
+
+ const result = await loadAuthValues({ userId: req.user.id, authFields: [EnvVar.CODE_API_KEY] });
+
+ /** @type {AxiosResponse | undefined} */
+ const response = await getDownloadStream(
+ `${session_id}/${fileId}`,
+ result[EnvVar.CODE_API_KEY],
+ );
+ res.set(response.headers);
+ response.data.pipe(res);
+ } catch (error) {
+ logger.error('Error downloading file:', error);
+ res.status(500).send('Error downloading file');
+ }
+});
+
+router.get('/download/:userId/:file_id', fileAccess, async (req, res) => {
+ try {
+ const { userId, file_id } = req.params;
+ logger.debug(`File download requested by user ${userId}: ${file_id}`);
+
+ // Access already validated by fileAccess middleware
+ const file = req.fileAccess.file;
+
+ if (checkOpenAIStorage(file.source) && !file.model) {
+ logger.warn(`File download requested by user ${userId} has no associated model: ${file_id}`);
+ return res.status(400).send('The model used when creating this file is not available');
+ }
+
+ const { getDownloadStream } = getStrategyFunctions(file.source);
+ if (!getDownloadStream) {
+ logger.warn(
+ `File download requested by user ${userId} has no stream method implemented: ${file.source}`,
+ );
+ return res.status(501).send('Not Implemented');
+ }
+
+ const setHeaders = () => {
+ const cleanedFilename = cleanFileName(file.filename);
+ res.setHeader('Content-Disposition', `attachment; filename="${cleanedFilename}"`);
+ res.setHeader('Content-Type', 'application/octet-stream');
+ res.setHeader('X-File-Metadata', JSON.stringify(file));
+ };
+
+ if (checkOpenAIStorage(file.source)) {
+ req.body = { model: file.model };
+ const endpointMap = {
+ [FileSources.openai]: EModelEndpoint.assistants,
+ [FileSources.azure]: EModelEndpoint.azureAssistants,
+ };
+ const { openai } = await getOpenAIClient({
+ req,
+ res,
+ overrideEndpoint: endpointMap[file.source],
+ });
+ logger.debug(`Downloading file ${file_id} from OpenAI`);
+ const passThrough = await getDownloadStream(file_id, openai);
+ setHeaders();
+ logger.debug(`File ${file_id} downloaded from OpenAI`);
+
+ // Handle both Node.js and Web streams
+ const stream =
+ passThrough.body && typeof passThrough.body.getReader === 'function'
+ ? Readable.fromWeb(passThrough.body)
+ : passThrough.body;
+
+ stream.pipe(res);
+ } else {
+ const fileStream = await getDownloadStream(req, file.filepath);
+
+ fileStream.on('error', (streamError) => {
+ logger.error('[DOWNLOAD ROUTE] Stream error:', streamError);
+ });
+
+ setHeaders();
+ fileStream.pipe(res);
+ }
+ } catch (error) {
+ logger.error('[DOWNLOAD ROUTE] Error downloading file:', error);
+ res.status(500).send('Error downloading file');
+ }
+});
+
+router.post('/', async (req, res) => {
+ const metadata = req.body;
+ let cleanup = true;
+
+ try {
+ filterFile({ req });
+
+ metadata.temp_file_id = metadata.file_id;
+ metadata.file_id = req.file_id;
+
+ if (isAssistantsEndpoint(metadata.endpoint)) {
+ return await processFileUpload({ req, res, metadata });
+ }
+
+ return await processAgentFileUpload({ req, res, metadata });
+ } catch (error) {
+ let message = 'Error processing file';
+ logger.error('[/files] Error processing file:', error);
+
+ if (error.message?.includes('file_ids')) {
+ message += ': ' + error.message;
+ }
+
+ if (
+ error.message?.includes('Invalid file format') ||
+ error.message?.includes('No OCR result') ||
+ error.message?.includes('exceeds token limit')
+ ) {
+ message = error.message;
+ }
+
+ try {
+ await fs.unlink(req.file.path);
+ cleanup = false;
+ } catch (error) {
+ logger.error('[/files] Error deleting file:', error);
+ }
+ res.status(500).json({ message });
+ } finally {
+ if (cleanup) {
+ try {
+ await fs.unlink(req.file.path);
+ } catch (error) {
+ logger.error('[/files] Error deleting file after file processing:', error);
+ }
+ } else {
+ logger.debug('[/files] File processing completed without cleanup');
+ }
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/files/files.test.js b/api/server/routes/files/files.test.js
new file mode 100644
index 0000000000..1d548b44be
--- /dev/null
+++ b/api/server/routes/files/files.test.js
@@ -0,0 +1,426 @@
+const express = require('express');
+const request = require('supertest');
+const mongoose = require('mongoose');
+const { v4: uuidv4 } = require('uuid');
+const { createMethods } = require('@librechat/data-schemas');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const {
+ SystemRoles,
+ ResourceType,
+ AccessRoleIds,
+ PrincipalType,
+} = require('librechat-data-provider');
+const { createAgent } = require('~/models/Agent');
+const { createFile } = require('~/models');
+
+// Only mock the external dependencies that we don't want to test
+jest.mock('~/server/services/Files/process', () => ({
+ processDeleteRequest: jest.fn().mockResolvedValue({}),
+ filterFile: jest.fn(),
+ processFileUpload: jest.fn(),
+ processAgentFileUpload: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/strategies', () => ({
+ getStrategyFunctions: jest.fn(() => ({})),
+}));
+
+jest.mock('~/server/controllers/assistants/helpers', () => ({
+ getOpenAIClient: jest.fn(),
+}));
+
+jest.mock('~/server/services/Tools/credentials', () => ({
+ loadAuthValues: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/S3/crud', () => ({
+ refreshS3FileUrls: jest.fn(),
+}));
+
+jest.mock('~/cache', () => ({
+ getLogStores: jest.fn(() => ({
+ get: jest.fn(),
+ set: jest.fn(),
+ })),
+}));
+
+jest.mock('~/config', () => ({
+ logger: {
+ error: jest.fn(),
+ warn: jest.fn(),
+ debug: jest.fn(),
+ },
+}));
+
+const { processDeleteRequest } = require('~/server/services/Files/process');
+
+// Import the router after mocks
+const router = require('./files');
+
+describe('File Routes - Delete with Agent Access', () => {
+ let app;
+ let mongoServer;
+ let authorId;
+ let otherUserId;
+ let fileId;
+ let File;
+ let Agent;
+ let AclEntry;
+ let User;
+ let methods;
+ let modelsToCleanup = [];
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+
+ // Initialize all models using createModels
+ const { createModels } = require('@librechat/data-schemas');
+ const models = createModels(mongoose);
+
+ // Track which models we're adding
+ modelsToCleanup = Object.keys(models);
+
+ // Register models on mongoose.models so methods can access them
+ Object.assign(mongoose.models, models);
+
+ // Create methods with our test mongoose instance
+ methods = createMethods(mongoose);
+
+ // Now we can access models from the db/models
+ File = models.File;
+ Agent = models.Agent;
+ AclEntry = models.AclEntry;
+ User = models.User;
+
+ // Seed default roles using our methods
+ await methods.seedDefaultRoles();
+
+ app = express();
+ app.use(express.json());
+
+ app.use((req, res, next) => {
+ req.user = {
+ id: otherUserId || 'default-user',
+ role: SystemRoles.USER,
+ };
+ req.app = { locals: {} };
+ next();
+ });
+
+ app.use('/files', router);
+ });
+
+ afterAll(async () => {
+ // Clean up all collections before disconnecting
+ const collections = mongoose.connection.collections;
+ for (const key in collections) {
+ await collections[key].deleteMany({});
+ }
+
+ // Clear only the models we added
+ for (const modelName of modelsToCleanup) {
+ if (mongoose.models[modelName]) {
+ delete mongoose.models[modelName];
+ }
+ }
+
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+
+ // Clear database - clean up all test data
+ await File.deleteMany({});
+ await Agent.deleteMany({});
+ await User.deleteMany({});
+ await AclEntry.deleteMany({});
+ // Don't delete AccessRole as they are seeded defaults needed for tests
+
+ // Create test data
+ authorId = new mongoose.Types.ObjectId();
+ otherUserId = new mongoose.Types.ObjectId();
+ fileId = uuidv4();
+
+ // Create users in database
+ await User.create({
+ _id: authorId,
+ username: 'author',
+ email: 'author@test.com',
+ });
+
+ await User.create({
+ _id: otherUserId,
+ username: 'other',
+ email: 'other@test.com',
+ });
+
+ // Create a file owned by the author
+ await createFile({
+ user: authorId,
+ file_id: fileId,
+ filename: 'test.txt',
+ filepath: '/uploads/test.txt',
+ bytes: 100,
+ type: 'text/plain',
+ });
+ });
+
+ describe('DELETE /files', () => {
+ it('should allow deleting files owned by the user', async () => {
+ // Create a file owned by the current user
+ const userFileId = uuidv4();
+ await createFile({
+ user: otherUserId,
+ file_id: userFileId,
+ filename: 'user-file.txt',
+ filepath: '/uploads/user-file.txt',
+ bytes: 200,
+ type: 'text/plain',
+ });
+
+ const response = await request(app)
+ .delete('/files')
+ .send({
+ files: [
+ {
+ file_id: userFileId,
+ filepath: '/uploads/user-file.txt',
+ },
+ ],
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.message).toBe('Files deleted successfully');
+ expect(processDeleteRequest).toHaveBeenCalled();
+ });
+
+ it('should prevent deleting files not owned by user without agent context', async () => {
+ const response = await request(app)
+ .delete('/files')
+ .send({
+ files: [
+ {
+ file_id: fileId,
+ filepath: '/uploads/test.txt',
+ },
+ ],
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body.message).toBe('You can only delete files you have access to');
+ expect(response.body.unauthorizedFiles).toContain(fileId);
+ expect(processDeleteRequest).not.toHaveBeenCalled();
+ });
+
+ it('should allow deleting files accessible through shared agent', async () => {
+ // Create an agent with the file attached
+ const agent = await createAgent({
+ id: uuidv4(),
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: [fileId],
+ },
+ },
+ });
+
+ // Grant EDIT permission to user on the agent
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_EDITOR,
+ grantedBy: authorId,
+ });
+
+ const response = await request(app)
+ .delete('/files')
+ .send({
+ agent_id: agent.id,
+ files: [
+ {
+ file_id: fileId,
+ filepath: '/uploads/test.txt',
+ },
+ ],
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.message).toBe('Files deleted successfully');
+ expect(processDeleteRequest).toHaveBeenCalled();
+ });
+
+ it('should prevent deleting files not attached to the specified agent', async () => {
+ // Create another file not attached to the agent
+ const unattachedFileId = uuidv4();
+ await createFile({
+ user: authorId,
+ file_id: unattachedFileId,
+ filename: 'unattached.txt',
+ filepath: '/uploads/unattached.txt',
+ bytes: 300,
+ type: 'text/plain',
+ });
+
+ // Create an agent without the unattached file
+ const agent = await createAgent({
+ id: uuidv4(),
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: [fileId], // Only fileId, not unattachedFileId
+ },
+ },
+ });
+
+ // Grant EDIT permission to user on the agent
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_EDITOR,
+ grantedBy: authorId,
+ });
+
+ const response = await request(app)
+ .delete('/files')
+ .send({
+ agent_id: agent.id,
+ files: [
+ {
+ file_id: unattachedFileId,
+ filepath: '/uploads/unattached.txt',
+ },
+ ],
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body.message).toBe('You can only delete files you have access to');
+ expect(response.body.unauthorizedFiles).toContain(unattachedFileId);
+ expect(processDeleteRequest).not.toHaveBeenCalled();
+ });
+
+ it('should handle mixed authorized and unauthorized files', async () => {
+ // Create a file owned by the current user
+ const userFileId = uuidv4();
+ await createFile({
+ user: otherUserId,
+ file_id: userFileId,
+ filename: 'user-file.txt',
+ filepath: '/uploads/user-file.txt',
+ bytes: 200,
+ type: 'text/plain',
+ });
+
+ // Create an unauthorized file
+ const unauthorizedFileId = uuidv4();
+ await createFile({
+ user: authorId,
+ file_id: unauthorizedFileId,
+ filename: 'unauthorized.txt',
+ filepath: '/uploads/unauthorized.txt',
+ bytes: 400,
+ type: 'text/plain',
+ });
+
+ // Create an agent with only fileId attached
+ const agent = await createAgent({
+ id: uuidv4(),
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: [fileId],
+ },
+ },
+ });
+
+ // Grant EDIT permission to user on the agent
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_EDITOR,
+ grantedBy: authorId,
+ });
+
+ const response = await request(app)
+ .delete('/files')
+ .send({
+ agent_id: agent.id,
+ files: [
+ { file_id: userFileId, filepath: '/uploads/user-file.txt' },
+ { file_id: fileId, filepath: '/uploads/test.txt' },
+ { file_id: unauthorizedFileId, filepath: '/uploads/unauthorized.txt' },
+ ],
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body.message).toBe('You can only delete files you have access to');
+ expect(response.body.unauthorizedFiles).toContain(unauthorizedFileId);
+ expect(processDeleteRequest).not.toHaveBeenCalled();
+ });
+
+ it('should prevent deleting files when user lacks EDIT permission on agent', async () => {
+ // Create an agent with the file attached
+ const agent = await createAgent({
+ id: uuidv4(),
+ name: 'Test Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: authorId,
+ tool_resources: {
+ file_search: {
+ file_ids: [fileId],
+ },
+ },
+ });
+
+ // Grant only VIEW permission to user on the agent
+ const { grantPermission } = require('~/server/services/PermissionService');
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: otherUserId,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ accessRoleId: AccessRoleIds.AGENT_VIEWER,
+ grantedBy: authorId,
+ });
+
+ const response = await request(app)
+ .delete('/files')
+ .send({
+ agent_id: agent.id,
+ files: [
+ {
+ file_id: fileId,
+ filepath: '/uploads/test.txt',
+ },
+ ],
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body.message).toBe('You can only delete files you have access to');
+ expect(response.body.unauthorizedFiles).toContain(fileId);
+ expect(processDeleteRequest).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/api/server/routes/files/images.js b/api/server/routes/files/images.js
new file mode 100644
index 0000000000..b8be413f4f
--- /dev/null
+++ b/api/server/routes/files/images.js
@@ -0,0 +1,64 @@
+const path = require('path');
+const fs = require('fs').promises;
+const express = require('express');
+const { logger } = require('@librechat/data-schemas');
+const { isAgentsEndpoint } = require('librechat-data-provider');
+const {
+ filterFile,
+ processImageFile,
+ processAgentFileUpload,
+} = require('~/server/services/Files/process');
+
+const router = express.Router();
+
+router.post('/', async (req, res) => {
+ const metadata = req.body;
+ const appConfig = req.config;
+
+ try {
+ filterFile({ req, image: true });
+
+ metadata.temp_file_id = metadata.file_id;
+ metadata.file_id = req.file_id;
+
+ if (isAgentsEndpoint(metadata.endpoint) && metadata.tool_resource != null) {
+ return await processAgentFileUpload({ req, res, metadata });
+ }
+
+ await processImageFile({ req, res, metadata });
+ } catch (error) {
+ // TODO: delete remote file if it exists
+ logger.error('[/files/images] Error processing file:', error);
+
+ let message = 'Error processing file';
+
+ if (
+ error.message?.includes('Invalid file format') ||
+ error.message?.includes('No OCR result') ||
+ error.message?.includes('exceeds token limit')
+ ) {
+ message = error.message;
+ }
+
+ try {
+ const filepath = path.join(
+ appConfig.paths.imageOutput,
+ req.user.id,
+ path.basename(req.file.filename),
+ );
+ await fs.unlink(filepath);
+ } catch (error) {
+ logger.error('[/files/images] Error deleting file:', error);
+ }
+ res.status(500).json({ message });
+ } finally {
+ try {
+ await fs.unlink(req.file.path);
+ logger.debug('[/files/images] Temp. image upload file deleted');
+ } catch {
+ logger.debug('[/files/images] Temp. image upload file already deleted');
+ }
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/files/index.js b/api/server/routes/files/index.js
new file mode 100644
index 0000000000..5d32b91036
--- /dev/null
+++ b/api/server/routes/files/index.js
@@ -0,0 +1,60 @@
+const express = require('express');
+const {
+ createFileLimiters,
+ configMiddleware,
+ requireJwtAuth,
+ uaParser,
+ checkBan,
+} = require('~/server/middleware');
+const { avatar: asstAvatarRouter } = require('~/server/routes/assistants/v1');
+const { avatar: agentAvatarRouter } = require('~/server/routes/agents/v1');
+const { createMulterInstance } = require('./multer');
+
+const files = require('./files');
+const images = require('./images');
+const avatar = require('./avatar');
+const speech = require('./speech');
+
+const initialize = async () => {
+ const router = express.Router();
+ router.use(requireJwtAuth);
+ router.use(configMiddleware);
+ router.use(checkBan);
+ router.use(uaParser);
+
+ const upload = await createMulterInstance();
+ router.post('/speech/stt', upload.single('audio'));
+
+ /* Important: speech route must be added before the upload limiters */
+ router.use('/speech', speech);
+
+ const { fileUploadIpLimiter, fileUploadUserLimiter } = createFileLimiters();
+
+ /** Apply rate limiters to all POST routes (excluding /speech which is handled above) */
+ router.use((req, res, next) => {
+ if (req.method === 'POST' && !req.path.startsWith('/speech')) {
+ return fileUploadIpLimiter(req, res, (err) => {
+ if (err) {
+ return next(err);
+ }
+ return fileUploadUserLimiter(req, res, next);
+ });
+ }
+ next();
+ });
+
+ router.post('/', upload.single('file'));
+ router.post('/images', upload.single('file'));
+ router.post('/images/avatar', upload.single('file'));
+ router.post('/images/agents/:agent_id/avatar', upload.single('file'));
+ router.post('/images/assistants/:assistant_id/avatar', upload.single('file'));
+
+ router.use('/', files);
+ router.use('/images', images);
+ router.use('/images/avatar', avatar);
+ router.use('/images/agents', agentAvatarRouter);
+ router.use('/images/assistants', asstAvatarRouter);
+ return router;
+};
+
+module.exports = { initialize };
diff --git a/api/server/routes/files/multer.js b/api/server/routes/files/multer.js
new file mode 100644
index 0000000000..cb155e2ac2
--- /dev/null
+++ b/api/server/routes/files/multer.js
@@ -0,0 +1,88 @@
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const multer = require('multer');
+const { sanitizeFilename } = require('@librechat/api');
+const {
+ mergeFileConfig,
+ getEndpointFileConfig,
+ fileConfig: defaultFileConfig,
+} = require('librechat-data-provider');
+const { getAppConfig } = require('~/server/services/Config');
+
+const storage = multer.diskStorage({
+ destination: function (req, file, cb) {
+ const appConfig = req.config;
+ const outputPath = path.join(appConfig.paths.uploads, 'temp', req.user.id);
+ if (!fs.existsSync(outputPath)) {
+ fs.mkdirSync(outputPath, { recursive: true });
+ }
+ cb(null, outputPath);
+ },
+ filename: function (req, file, cb) {
+ req.file_id = crypto.randomUUID();
+ file.originalname = decodeURIComponent(file.originalname);
+ const sanitizedFilename = sanitizeFilename(file.originalname);
+ cb(null, sanitizedFilename);
+ },
+});
+
+const importFileFilter = (req, file, cb) => {
+ if (file.mimetype === 'application/json') {
+ cb(null, true);
+ } else if (path.extname(file.originalname).toLowerCase() === '.json') {
+ cb(null, true);
+ } else {
+ cb(new Error('Only JSON files are allowed'), false);
+ }
+};
+
+/**
+ *
+ * @param {import('librechat-data-provider').FileConfig | undefined} customFileConfig
+ */
+const createFileFilter = (customFileConfig) => {
+ /**
+ * @param {ServerRequest} req
+ * @param {Express.Multer.File}
+ * @param {import('multer').FileFilterCallback} cb
+ */
+ const fileFilter = (req, file, cb) => {
+ if (!file) {
+ return cb(new Error('No file provided'), false);
+ }
+
+ if (req.originalUrl.endsWith('/speech/stt') && file.mimetype.startsWith('audio/')) {
+ return cb(null, true);
+ }
+
+ const endpoint = req.body.endpoint;
+ const endpointType = req.body.endpointType;
+ const endpointFileConfig = getEndpointFileConfig({
+ fileConfig: customFileConfig,
+ endpoint,
+ endpointType,
+ });
+
+ if (!defaultFileConfig.checkType(file.mimetype, endpointFileConfig.supportedMimeTypes)) {
+ return cb(new Error('Unsupported file type: ' + file.mimetype), false);
+ }
+
+ cb(null, true);
+ };
+
+ return fileFilter;
+};
+
+const createMulterInstance = async () => {
+ const appConfig = await getAppConfig();
+ const fileConfig = mergeFileConfig(appConfig?.fileConfig);
+ const fileFilter = createFileFilter(fileConfig);
+ return multer({
+ storage,
+ fileFilter,
+ limits: { fileSize: fileConfig.serverFileSizeLimit },
+ });
+};
+
+module.exports = { createMulterInstance, storage, importFileFilter };
diff --git a/api/server/routes/files/multer.spec.js b/api/server/routes/files/multer.spec.js
new file mode 100644
index 0000000000..84b97fe789
--- /dev/null
+++ b/api/server/routes/files/multer.spec.js
@@ -0,0 +1,549 @@
+/* eslint-disable no-unused-vars */
+/* eslint-disable jest/no-done-callback */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const crypto = require('crypto');
+const { createMulterInstance, storage, importFileFilter } = require('./multer');
+
+// Mock only the config service that requires external dependencies
+jest.mock('~/server/services/Config', () => ({
+ getAppConfig: jest.fn(),
+}));
+
+describe('Multer Configuration', () => {
+ let tempDir;
+ let mockReq;
+ let mockFile;
+
+ beforeEach(() => {
+ // Create a temporary directory for each test
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'multer-test-'));
+
+ mockReq = {
+ user: { id: 'test-user-123' },
+ body: {},
+ originalUrl: '/api/files/upload',
+ config: {
+ paths: {
+ uploads: tempDir,
+ },
+ },
+ };
+
+ mockFile = {
+ originalname: 'test-file.jpg',
+ mimetype: 'image/jpeg',
+ size: 1024,
+ };
+
+ // Clear mocks
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ // Clean up temporary directory
+ if (fs.existsSync(tempDir)) {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ describe('Storage Configuration', () => {
+ describe('destination function', () => {
+ it('should create the correct destination path', (done) => {
+ const cb = jest.fn((err, destination) => {
+ expect(err).toBeNull();
+ expect(destination).toBe(path.join(tempDir, 'temp', 'test-user-123'));
+ expect(fs.existsSync(destination)).toBe(true);
+ done();
+ });
+
+ storage.getDestination(mockReq, mockFile, cb);
+ });
+
+ it("should create directory recursively if it doesn't exist", (done) => {
+ const deepPath = path.join(tempDir, 'deep', 'nested', 'path');
+ mockReq.config.paths.uploads = deepPath;
+
+ const cb = jest.fn((err, destination) => {
+ expect(err).toBeNull();
+ expect(destination).toBe(path.join(deepPath, 'temp', 'test-user-123'));
+ expect(fs.existsSync(destination)).toBe(true);
+ done();
+ });
+
+ storage.getDestination(mockReq, mockFile, cb);
+ });
+ });
+
+ describe('filename function', () => {
+ it('should generate a UUID for req.file_id', (done) => {
+ const cb = jest.fn((err, filename) => {
+ expect(err).toBeNull();
+ expect(mockReq.file_id).toBeDefined();
+ expect(mockReq.file_id).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
+ );
+ done();
+ });
+
+ storage.getFilename(mockReq, mockFile, cb);
+ });
+
+ it('should decode URI components in filename', (done) => {
+ const encodedFile = {
+ ...mockFile,
+ originalname: encodeURIComponent('test file with spaces.jpg'),
+ };
+
+ const cb = jest.fn((err, filename) => {
+ expect(err).toBeNull();
+ expect(encodedFile.originalname).toBe('test file with spaces.jpg');
+ done();
+ });
+
+ storage.getFilename(mockReq, encodedFile, cb);
+ });
+
+ it('should call real sanitizeFilename with properly encoded filename', (done) => {
+ // Test with a properly URI-encoded filename that needs sanitization
+ const unsafeFile = {
+ ...mockFile,
+ originalname: encodeURIComponent('test@#$%^&*()file with spaces!.jpg'),
+ };
+
+ const cb = jest.fn((err, filename) => {
+ expect(err).toBeNull();
+ // The actual sanitizeFilename should have cleaned this up after decoding
+ expect(filename).not.toContain('@');
+ expect(filename).not.toContain('#');
+ expect(filename).not.toContain('*');
+ expect(filename).not.toContain('!');
+ // Should still preserve dots and hyphens
+ expect(filename).toContain('.jpg');
+ done();
+ });
+
+ storage.getFilename(mockReq, unsafeFile, cb);
+ });
+
+ it('should handle very long filenames with actual crypto', (done) => {
+ const longFile = {
+ ...mockFile,
+ originalname: 'a'.repeat(300) + '.jpg',
+ };
+
+ const cb = jest.fn((err, filename) => {
+ expect(err).toBeNull();
+ expect(filename.length).toBeLessThanOrEqual(255);
+ expect(filename).toMatch(/\.jpg$/); // Should still end with .jpg
+ // Should contain a hex suffix if truncated
+ if (filename.length === 255) {
+ expect(filename).toMatch(/-[a-f0-9]{6}\.jpg$/);
+ }
+ done();
+ });
+
+ storage.getFilename(mockReq, longFile, cb);
+ });
+
+ it('should generate unique file_id for each call', (done) => {
+ let firstFileId;
+
+ const firstCb = jest.fn((err, filename) => {
+ expect(err).toBeNull();
+ firstFileId = mockReq.file_id;
+
+ // Reset req for second call
+ delete mockReq.file_id;
+
+ const secondCb = jest.fn((err, filename) => {
+ expect(err).toBeNull();
+ expect(mockReq.file_id).toBeDefined();
+ expect(mockReq.file_id).not.toBe(firstFileId);
+ done();
+ });
+
+ storage.getFilename(mockReq, mockFile, secondCb);
+ });
+
+ storage.getFilename(mockReq, mockFile, firstCb);
+ });
+ });
+ });
+
+ describe('Import File Filter', () => {
+ it('should accept JSON files by mimetype', (done) => {
+ const jsonFile = {
+ ...mockFile,
+ mimetype: 'application/json',
+ originalname: 'data.json',
+ };
+
+ const cb = jest.fn((err, result) => {
+ expect(err).toBeNull();
+ expect(result).toBe(true);
+ done();
+ });
+
+ importFileFilter(mockReq, jsonFile, cb);
+ });
+
+ it('should accept files with .json extension', (done) => {
+ const jsonFile = {
+ ...mockFile,
+ mimetype: 'text/plain',
+ originalname: 'data.json',
+ };
+
+ const cb = jest.fn((err, result) => {
+ expect(err).toBeNull();
+ expect(result).toBe(true);
+ done();
+ });
+
+ importFileFilter(mockReq, jsonFile, cb);
+ });
+
+ it('should reject non-JSON files', (done) => {
+ const textFile = {
+ ...mockFile,
+ mimetype: 'text/plain',
+ originalname: 'document.txt',
+ };
+
+ const cb = jest.fn((err, result) => {
+ expect(err).toBeInstanceOf(Error);
+ expect(err.message).toBe('Only JSON files are allowed');
+ expect(result).toBe(false);
+ done();
+ });
+
+ importFileFilter(mockReq, textFile, cb);
+ });
+
+ it('should handle files with uppercase .JSON extension', (done) => {
+ const jsonFile = {
+ ...mockFile,
+ mimetype: 'text/plain',
+ originalname: 'DATA.JSON',
+ };
+
+ const cb = jest.fn((err, result) => {
+ expect(err).toBeNull();
+ expect(result).toBe(true);
+ done();
+ });
+
+ importFileFilter(mockReq, jsonFile, cb);
+ });
+ });
+
+ describe('File Filter with Real defaultFileConfig', () => {
+ it('should use real fileConfig.checkType for validation', async () => {
+ // Test with actual librechat-data-provider functions
+ const {
+ fileConfig,
+ imageMimeTypes,
+ applicationMimeTypes,
+ } = require('librechat-data-provider');
+
+ // Test that the real checkType function works with regex patterns
+ expect(fileConfig.checkType('image/jpeg', [imageMimeTypes])).toBe(true);
+ expect(fileConfig.checkType('video/mp4', [imageMimeTypes])).toBe(false);
+ expect(fileConfig.checkType('application/pdf', [applicationMimeTypes])).toBe(true);
+ expect(fileConfig.checkType('application/pdf', [])).toBe(false);
+ });
+
+ it('should handle audio files for speech-to-text endpoint with real config', async () => {
+ mockReq.originalUrl = '/api/speech/stt';
+
+ const multerInstance = await createMulterInstance();
+ expect(multerInstance).toBeDefined();
+ expect(typeof multerInstance.single).toBe('function');
+ });
+
+ it('should reject unsupported file types using real config', async () => {
+ // Mock defaultFileConfig for this specific test
+ const originalCheckType = require('librechat-data-provider').fileConfig.checkType;
+ const mockCheckType = jest.fn().mockReturnValue(false);
+ require('librechat-data-provider').fileConfig.checkType = mockCheckType;
+
+ try {
+ const multerInstance = await createMulterInstance();
+ expect(multerInstance).toBeDefined();
+
+ // Test the actual file filter behavior would reject unsupported files
+ expect(mockCheckType).toBeDefined();
+ } finally {
+ // Restore original function
+ require('librechat-data-provider').fileConfig.checkType = originalCheckType;
+ }
+ });
+
+ it('should use real mergeFileConfig function', async () => {
+ const { mergeFileConfig, mbToBytes } = require('librechat-data-provider');
+
+ // Test with actual merge function - note that it converts MB to bytes
+ const testConfig = {
+ serverFileSizeLimit: 5, // 5 MB
+ endpoints: {
+ custom: {
+ supportedMimeTypes: ['text/plain'],
+ },
+ },
+ };
+
+ const result = mergeFileConfig(testConfig);
+
+ // The function converts MB to bytes, so 5 MB becomes 5 * 1024 * 1024 bytes
+ expect(result.serverFileSizeLimit).toBe(mbToBytes(5));
+ expect(result.endpoints.custom.supportedMimeTypes).toBeDefined();
+ // Should still have the default endpoints
+ expect(result.endpoints.default).toBeDefined();
+ });
+ });
+
+ describe('createMulterInstance with Real Functions', () => {
+ it('should create a multer instance with correct configuration', async () => {
+ const multerInstance = await createMulterInstance();
+
+ expect(multerInstance).toBeDefined();
+ expect(typeof multerInstance.single).toBe('function');
+ expect(typeof multerInstance.array).toBe('function');
+ expect(typeof multerInstance.fields).toBe('function');
+ });
+
+ it('should use real config merging', async () => {
+ const { getAppConfig } = require('~/server/services/Config');
+
+ const multerInstance = await createMulterInstance();
+
+ expect(getAppConfig).toHaveBeenCalled();
+ expect(multerInstance).toBeDefined();
+ });
+
+ it('should create multer instance with expected interface', async () => {
+ const multerInstance = await createMulterInstance();
+
+ expect(multerInstance).toBeDefined();
+ expect(typeof multerInstance.single).toBe('function');
+ expect(typeof multerInstance.array).toBe('function');
+ expect(typeof multerInstance.fields).toBe('function');
+ });
+ });
+
+ describe('Real Crypto Integration', () => {
+ it('should use actual crypto.randomUUID()', (done) => {
+ // Spy on crypto.randomUUID to ensure it's called
+ const uuidSpy = jest.spyOn(crypto, 'randomUUID');
+
+ const cb = jest.fn((err, filename) => {
+ expect(err).toBeNull();
+ expect(uuidSpy).toHaveBeenCalled();
+ expect(mockReq.file_id).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
+ );
+
+ uuidSpy.mockRestore();
+ done();
+ });
+
+ storage.getFilename(mockReq, mockFile, cb);
+ });
+
+ it('should generate different UUIDs on subsequent calls', (done) => {
+ const uuids = [];
+ let callCount = 0;
+ const totalCalls = 5;
+
+ const cb = jest.fn((err, filename) => {
+ expect(err).toBeNull();
+ uuids.push(mockReq.file_id);
+ callCount++;
+
+ if (callCount === totalCalls) {
+ // Check that all UUIDs are unique
+ const uniqueUuids = new Set(uuids);
+ expect(uniqueUuids.size).toBe(totalCalls);
+ done();
+ } else {
+ // Reset for next call
+ delete mockReq.file_id;
+ storage.getFilename(mockReq, mockFile, cb);
+ }
+ });
+
+ // Start the chain
+ storage.getFilename(mockReq, mockFile, cb);
+ });
+
+ it('should generate cryptographically secure UUIDs', (done) => {
+ const generatedUuids = new Set();
+ let callCount = 0;
+ const totalCalls = 10;
+
+ const cb = jest.fn((err, filename) => {
+ expect(err).toBeNull();
+
+ // Verify UUID format and uniqueness
+ expect(mockReq.file_id).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
+ );
+
+ generatedUuids.add(mockReq.file_id);
+ callCount++;
+
+ if (callCount === totalCalls) {
+ // All UUIDs should be unique
+ expect(generatedUuids.size).toBe(totalCalls);
+ done();
+ } else {
+ // Reset for next call
+ delete mockReq.file_id;
+ storage.getFilename(mockReq, mockFile, cb);
+ }
+ });
+
+ // Start the chain
+ storage.getFilename(mockReq, mockFile, cb);
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('should handle CVE-2024-28870: empty field name DoS vulnerability', async () => {
+ // Test for the CVE where empty field name could cause unhandled exception
+ const multerInstance = await createMulterInstance();
+
+ // Create a mock request with empty field name (the vulnerability scenario)
+ const mockReqWithEmptyField = {
+ ...mockReq,
+ headers: {
+ 'content-type': 'multipart/form-data',
+ },
+ };
+
+ const mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ end: jest.fn(),
+ };
+
+ // This should not crash or throw unhandled exceptions
+ const uploadMiddleware = multerInstance.single(''); // Empty field name
+
+ const mockNext = jest.fn((err) => {
+ // If there's an error, it should be handled gracefully, not crash
+ if (err) {
+ expect(err).toBeInstanceOf(Error);
+ // The error should be handled, not crash the process
+ }
+ });
+
+ // This should complete without crashing the process
+ expect(() => {
+ uploadMiddleware(mockReqWithEmptyField, mockRes, mockNext);
+ }).not.toThrow();
+ });
+
+ it('should handle file system errors when directory creation fails', () => {
+ // Test with a non-existent parent directory to simulate fs issues
+ const invalidPath = '/nonexistent/path/that/should/not/exist';
+ mockReq.config.paths.uploads = invalidPath;
+
+ // The current implementation doesn't catch errors, so they're thrown synchronously
+ expect(() => {
+ storage.getDestination(mockReq, mockFile, jest.fn());
+ }).toThrow();
+ });
+
+ it('should handle malformed filenames with real sanitization', (done) => {
+ const malformedFile = {
+ ...mockFile,
+ originalname: null, // This should be handled gracefully
+ };
+
+ const cb = jest.fn((err, filename) => {
+ // The function should handle this gracefully
+ expect(typeof err === 'object' || err === null).toBe(true);
+ done();
+ });
+
+ try {
+ storage.getFilename(mockReq, malformedFile, cb);
+ } catch (error) {
+ // If it throws, that's also acceptable behavior
+ done();
+ }
+ });
+
+ it('should handle edge cases in filename sanitization', (done) => {
+ const edgeCaseFiles = [
+ { originalname: '', expected: /_/ },
+ { originalname: '.hidden', expected: /^_\.hidden/ },
+ { originalname: '../../../etc/passwd', expected: /passwd/ },
+ { originalname: 'file\x00name.txt', expected: /file_name\.txt/ },
+ ];
+
+ let testCount = 0;
+
+ const testNextFile = (fileData) => {
+ const fileToTest = { ...mockFile, originalname: fileData.originalname };
+
+ const cb = jest.fn((err, filename) => {
+ expect(err).toBeNull();
+ expect(filename).toMatch(fileData.expected);
+
+ testCount++;
+ if (testCount === edgeCaseFiles.length) {
+ done();
+ } else {
+ testNextFile(edgeCaseFiles[testCount]);
+ }
+ });
+
+ storage.getFilename(mockReq, fileToTest, cb);
+ };
+
+ testNextFile(edgeCaseFiles[0]);
+ });
+ });
+
+ describe('Real Configuration Testing', () => {
+ it('should handle missing custom config gracefully with real mergeFileConfig', async () => {
+ const { getAppConfig } = require('~/server/services/Config');
+
+ // Mock getAppConfig to return undefined
+ getAppConfig.mockResolvedValueOnce(undefined);
+
+ const multerInstance = await createMulterInstance();
+ expect(multerInstance).toBeDefined();
+ expect(typeof multerInstance.single).toBe('function');
+ });
+
+ it('should properly integrate real fileConfig with custom endpoints', async () => {
+ const { getAppConfig } = require('~/server/services/Config');
+
+ // Mock appConfig with fileConfig
+ getAppConfig.mockResolvedValueOnce({
+ paths: {
+ uploads: tempDir,
+ },
+ fileConfig: {
+ endpoints: {
+ anthropic: {
+ supportedMimeTypes: ['text/plain', 'image/png'],
+ },
+ },
+ serverFileSizeLimit: 20971520, // 20 MB in bytes (mergeFileConfig converts)
+ },
+ });
+
+ const multerInstance = await createMulterInstance();
+ expect(multerInstance).toBeDefined();
+
+ // Verify that getAppConfig was called
+ expect(getAppConfig).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/api/server/routes/files/speech/customConfigSpeech.js b/api/server/routes/files/speech/customConfigSpeech.js
new file mode 100644
index 0000000000..c3b1e2eb47
--- /dev/null
+++ b/api/server/routes/files/speech/customConfigSpeech.js
@@ -0,0 +1,10 @@
+const express = require('express');
+const router = express.Router();
+
+const { getCustomConfigSpeech } = require('~/server/services/Files/Audio');
+
+router.get('/get', async (req, res) => {
+ await getCustomConfigSpeech(req, res);
+});
+
+module.exports = router;
diff --git a/api/server/routes/files/speech/index.js b/api/server/routes/files/speech/index.js
new file mode 100644
index 0000000000..074ed553c9
--- /dev/null
+++ b/api/server/routes/files/speech/index.js
@@ -0,0 +1,17 @@
+const express = require('express');
+const { createTTSLimiters, createSTTLimiters } = require('~/server/middleware');
+
+const stt = require('./stt');
+const tts = require('./tts');
+const customConfigSpeech = require('./customConfigSpeech');
+
+const router = express.Router();
+
+const { sttIpLimiter, sttUserLimiter } = createSTTLimiters();
+const { ttsIpLimiter, ttsUserLimiter } = createTTSLimiters();
+router.use('/stt', sttIpLimiter, sttUserLimiter, stt);
+router.use('/tts', ttsIpLimiter, ttsUserLimiter, tts);
+
+router.use('/config', customConfigSpeech);
+
+module.exports = router;
diff --git a/api/server/routes/files/speech/stt.js b/api/server/routes/files/speech/stt.js
new file mode 100644
index 0000000000..663d2e4638
--- /dev/null
+++ b/api/server/routes/files/speech/stt.js
@@ -0,0 +1,8 @@
+const express = require('express');
+const { speechToText } = require('~/server/services/Files/Audio');
+
+const router = express.Router();
+
+router.post('/', speechToText);
+
+module.exports = router;
diff --git a/api/server/routes/files/speech/tts.js b/api/server/routes/files/speech/tts.js
new file mode 100644
index 0000000000..ab1de205b7
--- /dev/null
+++ b/api/server/routes/files/speech/tts.js
@@ -0,0 +1,42 @@
+const multer = require('multer');
+const express = require('express');
+const { logger } = require('@librechat/data-schemas');
+const { CacheKeys } = require('librechat-data-provider');
+const { getVoices, streamAudio, textToSpeech } = require('~/server/services/Files/Audio');
+const { getLogStores } = require('~/cache');
+
+const router = express.Router();
+const upload = multer();
+
+router.post('/manual', upload.none(), async (req, res) => {
+ await textToSpeech(req, res);
+});
+
+const logDebugMessage = (req, message) =>
+ logger.debug(`[streamAudio] user: ${req?.user?.id ?? 'UNDEFINED_USER'} | ${message}`);
+
+// TODO: test caching
+router.post('/', async (req, res) => {
+ try {
+ const audioRunsCache = getLogStores(CacheKeys.AUDIO_RUNS);
+ const audioRun = await audioRunsCache.get(req.body.runId);
+ logDebugMessage(req, 'start stream audio');
+ if (audioRun) {
+ logDebugMessage(req, 'stream audio already running');
+ return res.status(401).json({ error: 'Audio stream already running' });
+ }
+ audioRunsCache.set(req.body.runId, true);
+ await streamAudio(req, res);
+ logDebugMessage(req, 'end stream audio');
+ res.status(200).end();
+ } catch (error) {
+ logger.error(`[streamAudio] user: ${req.user.id} | Failed to stream audio: ${error}`);
+ res.status(500).json({ error: 'Failed to stream audio' });
+ }
+});
+
+router.get('/voices', async (req, res) => {
+ await getVoices(req, res);
+});
+
+module.exports = router;
diff --git a/api/server/routes/handlers.js b/api/server/routes/handlers.js
deleted file mode 100644
index edd64e6184..0000000000
--- a/api/server/routes/handlers.js
+++ /dev/null
@@ -1,13 +0,0 @@
-const handleError = (res, errorMessage) => {
- res.status(500).write(`event: error\ndata: ${errorMessage}`);
- res.end();
-};
-
-const sendMessage = (res, message) => {
- if (message.length === 0) {
- return;
- }
- res.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`);
-};
-
-module.exports = { handleError, sendMessage };
diff --git a/api/server/routes/index.js b/api/server/routes/index.js
index 235d08da2f..f3571099cb 100644
--- a/api/server/routes/index.js
+++ b/api/server/routes/index.js
@@ -1,7 +1,55 @@
-const ask = require('./ask');
+const accessPermissions = require('./accessPermissions');
+const assistants = require('./assistants');
+const categories = require('./categories');
+const endpoints = require('./endpoints');
+const staticRoute = require('./static');
const messages = require('./messages');
-const convos = require('./convos');
-const customGpts = require('./customGpts');
+const memories = require('./memories');
+const presets = require('./presets');
const prompts = require('./prompts');
+const balance = require('./balance');
+const actions = require('./actions');
+const banner = require('./banner');
+const search = require('./search');
+const models = require('./models');
+const convos = require('./convos');
+const config = require('./config');
+const agents = require('./agents');
+const roles = require('./roles');
+const oauth = require('./oauth');
+const files = require('./files');
+const share = require('./share');
+const tags = require('./tags');
+const auth = require('./auth');
+const keys = require('./keys');
+const user = require('./user');
+const mcp = require('./mcp');
-module.exports = { ask, messages, convos, customGpts, prompts };
\ No newline at end of file
+module.exports = {
+ mcp,
+ auth,
+ keys,
+ user,
+ tags,
+ roles,
+ oauth,
+ files,
+ share,
+ banner,
+ agents,
+ convos,
+ search,
+ config,
+ models,
+ prompts,
+ actions,
+ presets,
+ balance,
+ messages,
+ memories,
+ endpoints,
+ assistants,
+ categories,
+ staticRoute,
+ accessPermissions,
+};
diff --git a/api/server/routes/keys.js b/api/server/routes/keys.js
new file mode 100644
index 0000000000..620e4d234b
--- /dev/null
+++ b/api/server/routes/keys.js
@@ -0,0 +1,36 @@
+const express = require('express');
+const { updateUserKey, deleteUserKey, getUserKeyExpiry } = require('~/models');
+const { requireJwtAuth } = require('~/server/middleware');
+
+const router = express.Router();
+
+router.put('/', requireJwtAuth, async (req, res) => {
+ await updateUserKey({ userId: req.user.id, ...req.body });
+ res.status(201).send();
+});
+
+router.delete('/:name', requireJwtAuth, async (req, res) => {
+ const { name } = req.params;
+ await deleteUserKey({ userId: req.user.id, name });
+ res.status(204).send();
+});
+
+router.delete('/', requireJwtAuth, async (req, res) => {
+ const { all } = req.query;
+
+ if (all !== 'true') {
+ return res.status(400).send({ error: 'Specify either all=true to delete.' });
+ }
+
+ await deleteUserKey({ userId: req.user.id, all: true });
+
+ res.status(204).send();
+});
+
+router.get('/', requireJwtAuth, async (req, res) => {
+ const { name } = req.query;
+ const response = await getUserKeyExpiry({ userId: req.user.id, name });
+ res.status(200).send(response);
+});
+
+module.exports = router;
diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js
new file mode 100644
index 0000000000..0cee7f991a
--- /dev/null
+++ b/api/server/routes/mcp.js
@@ -0,0 +1,679 @@
+const { Router } = require('express');
+const { logger } = require('@librechat/data-schemas');
+const {
+ CacheKeys,
+ Constants,
+ PermissionBits,
+ PermissionTypes,
+ Permissions,
+} = require('librechat-data-provider');
+const {
+ createSafeUser,
+ MCPOAuthHandler,
+ MCPTokenStorage,
+ getUserMCPAuthMap,
+ generateCheckAccess,
+} = require('@librechat/api');
+const {
+ getMCPManager,
+ getFlowStateManager,
+ getOAuthReconnectionManager,
+ getMCPServersRegistry,
+} = require('~/config');
+const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
+const { requireJwtAuth, canAccessMCPServerResource } = require('~/server/middleware');
+const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
+const { getUserPluginAuthValue } = require('~/server/services/PluginService');
+const { updateMCPServerTools } = require('~/server/services/Config/mcp');
+const { reinitMCPServer } = require('~/server/services/Tools/mcp');
+const { getMCPTools } = require('~/server/controllers/mcp');
+const { findPluginAuthsByKeys } = require('~/models');
+const { getRoleByName } = require('~/models/Role');
+const { getLogStores } = require('~/cache');
+const {
+ createMCPServerController,
+ getMCPServerById,
+ getMCPServersList,
+ updateMCPServerController,
+ deleteMCPServerController,
+} = require('~/server/controllers/mcp');
+
+const router = Router();
+
+/**
+ * Get all MCP tools available to the user
+ * Returns only MCP tools, completely decoupled from regular LibreChat tools
+ */
+router.get('/tools', requireJwtAuth, async (req, res) => {
+ return getMCPTools(req, res);
+});
+
+/**
+ * Initiate OAuth flow
+ * This endpoint is called when the user clicks the auth link in the UI
+ */
+router.get('/:serverName/oauth/initiate', requireJwtAuth, async (req, res) => {
+ try {
+ const { serverName } = req.params;
+ const { userId, flowId } = req.query;
+ const user = req.user;
+
+ // Verify the userId matches the authenticated user
+ if (userId !== user.id) {
+ return res.status(403).json({ error: 'User mismatch' });
+ }
+
+ logger.debug('[MCP OAuth] Initiate request', { serverName, userId, flowId });
+
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getFlowStateManager(flowsCache);
+
+ /** Flow state to retrieve OAuth config */
+ const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
+ if (!flowState) {
+ logger.error('[MCP OAuth] Flow state not found', { flowId });
+ return res.status(404).json({ error: 'Flow not found' });
+ }
+
+ const { serverUrl, oauth: oauthConfig } = flowState.metadata || {};
+ if (!serverUrl || !oauthConfig) {
+ logger.error('[MCP OAuth] Missing server URL or OAuth config in flow state');
+ return res.status(400).json({ error: 'Invalid flow state' });
+ }
+
+ const oauthHeaders = await getOAuthHeaders(serverName, userId);
+ const { authorizationUrl, flowId: oauthFlowId } = await MCPOAuthHandler.initiateOAuthFlow(
+ serverName,
+ serverUrl,
+ userId,
+ oauthHeaders,
+ oauthConfig,
+ );
+
+ logger.debug('[MCP OAuth] OAuth flow initiated', { oauthFlowId, authorizationUrl });
+
+ // Redirect user to the authorization URL
+ res.redirect(authorizationUrl);
+ } catch (error) {
+ logger.error('[MCP OAuth] Failed to initiate OAuth', error);
+ res.status(500).json({ error: 'Failed to initiate OAuth' });
+ }
+});
+
+/**
+ * OAuth callback handler
+ * This handles the OAuth callback after the user has authorized the application
+ */
+router.get('/:serverName/oauth/callback', async (req, res) => {
+ try {
+ const { serverName } = req.params;
+ const { code, state, error: oauthError } = req.query;
+
+ logger.debug('[MCP OAuth] Callback received', {
+ serverName,
+ code: code ? 'present' : 'missing',
+ state,
+ error: oauthError,
+ });
+
+ if (oauthError) {
+ logger.error('[MCP OAuth] OAuth error received', { error: oauthError });
+ return res.redirect(`/oauth/error?error=${encodeURIComponent(String(oauthError))}`);
+ }
+
+ if (!code || typeof code !== 'string') {
+ logger.error('[MCP OAuth] Missing or invalid code');
+ return res.redirect('/oauth/error?error=missing_code');
+ }
+
+ if (!state || typeof state !== 'string') {
+ logger.error('[MCP OAuth] Missing or invalid state');
+ return res.redirect('/oauth/error?error=missing_state');
+ }
+
+ const flowId = state;
+ logger.debug('[MCP OAuth] Using flow ID from state', { flowId });
+
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getFlowStateManager(flowsCache);
+
+ logger.debug('[MCP OAuth] Getting flow state for flowId: ' + flowId);
+ const flowState = await MCPOAuthHandler.getFlowState(flowId, flowManager);
+
+ if (!flowState) {
+ logger.error('[MCP OAuth] Flow state not found for flowId:', flowId);
+ return res.redirect('/oauth/error?error=invalid_state');
+ }
+
+ logger.debug('[MCP OAuth] Flow state details', {
+ serverName: flowState.serverName,
+ userId: flowState.userId,
+ hasMetadata: !!flowState.metadata,
+ hasClientInfo: !!flowState.clientInfo,
+ hasCodeVerifier: !!flowState.codeVerifier,
+ });
+
+ /** Check if this flow has already been completed (idempotency protection) */
+ const currentFlowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
+ if (currentFlowState?.status === 'COMPLETED') {
+ logger.warn('[MCP OAuth] Flow already completed, preventing duplicate token exchange', {
+ flowId,
+ serverName,
+ });
+ return res.redirect(`/oauth/success?serverName=${encodeURIComponent(serverName)}`);
+ }
+
+ logger.debug('[MCP OAuth] Completing OAuth flow');
+ const oauthHeaders = await getOAuthHeaders(serverName, flowState.userId);
+ const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager, oauthHeaders);
+ logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
+
+ /** Persist tokens immediately so reconnection uses fresh credentials */
+ if (flowState?.userId && tokens) {
+ try {
+ await MCPTokenStorage.storeTokens({
+ userId: flowState.userId,
+ serverName,
+ tokens,
+ createToken,
+ updateToken,
+ findToken,
+ clientInfo: flowState.clientInfo,
+ metadata: flowState.metadata,
+ });
+ logger.debug('[MCP OAuth] Stored OAuth tokens prior to reconnection', {
+ serverName,
+ userId: flowState.userId,
+ });
+ } catch (error) {
+ logger.error('[MCP OAuth] Failed to store OAuth tokens after callback', error);
+ throw error;
+ }
+
+ /**
+ * Clear any cached `mcp_get_tokens` flow result so subsequent lookups
+ * re-fetch the freshly stored credentials instead of returning stale nulls.
+ */
+ if (typeof flowManager?.deleteFlow === 'function') {
+ try {
+ await flowManager.deleteFlow(flowId, 'mcp_get_tokens');
+ } catch (error) {
+ logger.warn('[MCP OAuth] Failed to clear cached token flow state', error);
+ }
+ }
+ }
+
+ try {
+ const mcpManager = getMCPManager(flowState.userId);
+ logger.debug(`[MCP OAuth] Attempting to reconnect ${serverName} with new OAuth tokens`);
+
+ if (flowState.userId !== 'system') {
+ const user = { id: flowState.userId };
+
+ const userConnection = await mcpManager.getUserConnection({
+ user,
+ serverName,
+ flowManager,
+ tokenMethods: {
+ findToken,
+ updateToken,
+ createToken,
+ deleteTokens,
+ },
+ });
+
+ logger.info(
+ `[MCP OAuth] Successfully reconnected ${serverName} for user ${flowState.userId}`,
+ );
+
+ // clear any reconnection attempts
+ const oauthReconnectionManager = getOAuthReconnectionManager();
+ oauthReconnectionManager.clearReconnection(flowState.userId, serverName);
+
+ const tools = await userConnection.fetchTools();
+ await updateMCPServerTools({
+ userId: flowState.userId,
+ serverName,
+ tools,
+ });
+ } else {
+ logger.debug(`[MCP OAuth] System-level OAuth completed for ${serverName}`);
+ }
+ } catch (error) {
+ logger.warn(
+ `[MCP OAuth] Failed to reconnect ${serverName} after OAuth, but tokens are saved:`,
+ error,
+ );
+ }
+
+ /** ID of the flow that the tool/connection is waiting for */
+ const toolFlowId = flowState.metadata?.toolFlowId;
+ if (toolFlowId) {
+ logger.debug('[MCP OAuth] Completing tool flow', { toolFlowId });
+ await flowManager.completeFlow(toolFlowId, 'mcp_oauth', tokens);
+ }
+
+ /** Redirect to success page with flowId and serverName */
+ const redirectUrl = `/oauth/success?serverName=${encodeURIComponent(serverName)}`;
+ res.redirect(redirectUrl);
+ } catch (error) {
+ logger.error('[MCP OAuth] OAuth callback error', error);
+ res.redirect('/oauth/error?error=callback_failed');
+ }
+});
+
+/**
+ * Get OAuth tokens for a completed flow
+ * This is primarily for user-level OAuth flows
+ */
+router.get('/oauth/tokens/:flowId', requireJwtAuth, async (req, res) => {
+ try {
+ const { flowId } = req.params;
+ const user = req.user;
+
+ if (!user?.id) {
+ return res.status(401).json({ error: 'User not authenticated' });
+ }
+
+ if (!flowId.startsWith(`${user.id}:`) && !flowId.startsWith('system:')) {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getFlowStateManager(flowsCache);
+
+ const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
+ if (!flowState) {
+ return res.status(404).json({ error: 'Flow not found' });
+ }
+
+ if (flowState.status !== 'COMPLETED') {
+ return res.status(400).json({ error: 'Flow not completed' });
+ }
+
+ res.json({ tokens: flowState.result });
+ } catch (error) {
+ logger.error('[MCP OAuth] Failed to get tokens', error);
+ res.status(500).json({ error: 'Failed to get tokens' });
+ }
+});
+
+/**
+ * Check OAuth flow status
+ * This endpoint can be used to poll the status of an OAuth flow
+ */
+router.get('/oauth/status/:flowId', async (req, res) => {
+ try {
+ const { flowId } = req.params;
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getFlowStateManager(flowsCache);
+
+ const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
+ if (!flowState) {
+ return res.status(404).json({ error: 'Flow not found' });
+ }
+
+ res.json({
+ status: flowState.status,
+ completed: flowState.status === 'COMPLETED',
+ failed: flowState.status === 'FAILED',
+ error: flowState.error,
+ });
+ } catch (error) {
+ logger.error('[MCP OAuth] Failed to get flow status', error);
+ res.status(500).json({ error: 'Failed to get flow status' });
+ }
+});
+
+/**
+ * Cancel OAuth flow
+ * This endpoint cancels a pending OAuth flow
+ */
+router.post('/oauth/cancel/:serverName', requireJwtAuth, async (req, res) => {
+ try {
+ const { serverName } = req.params;
+ const user = req.user;
+
+ if (!user?.id) {
+ return res.status(401).json({ error: 'User not authenticated' });
+ }
+
+ logger.info(`[MCP OAuth Cancel] Cancelling OAuth flow for ${serverName} by user ${user.id}`);
+
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getFlowStateManager(flowsCache);
+ const flowId = MCPOAuthHandler.generateFlowId(user.id, serverName);
+ const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
+
+ if (!flowState) {
+ logger.debug(`[MCP OAuth Cancel] No active flow found for ${serverName}`);
+ return res.json({
+ success: true,
+ message: 'No active OAuth flow to cancel',
+ });
+ }
+
+ await flowManager.failFlow(flowId, 'mcp_oauth', 'User cancelled OAuth flow');
+
+ logger.info(`[MCP OAuth Cancel] Successfully cancelled OAuth flow for ${serverName}`);
+
+ res.json({
+ success: true,
+ message: `OAuth flow for ${serverName} cancelled successfully`,
+ });
+ } catch (error) {
+ logger.error('[MCP OAuth Cancel] Failed to cancel OAuth flow', error);
+ res.status(500).json({ error: 'Failed to cancel OAuth flow' });
+ }
+});
+
+/**
+ * Reinitialize MCP server
+ * This endpoint allows reinitializing a specific MCP server
+ */
+router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
+ try {
+ const { serverName } = req.params;
+ const user = createSafeUser(req.user);
+
+ if (!user.id) {
+ return res.status(401).json({ error: 'User not authenticated' });
+ }
+
+ logger.info(`[MCP Reinitialize] Reinitializing server: ${serverName}`);
+
+ const mcpManager = getMCPManager();
+ const serverConfig = await getMCPServersRegistry().getServerConfig(serverName, user.id);
+ if (!serverConfig) {
+ return res.status(404).json({
+ error: `MCP server '${serverName}' not found in configuration`,
+ });
+ }
+
+ await mcpManager.disconnectUserConnection(user.id, serverName);
+ logger.info(
+ `[MCP Reinitialize] Disconnected existing user connection for server: ${serverName}`,
+ );
+
+ /** @type {Record> | undefined} */
+ let userMCPAuthMap;
+ if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
+ userMCPAuthMap = await getUserMCPAuthMap({
+ userId: user.id,
+ servers: [serverName],
+ findPluginAuthsByKeys,
+ });
+ }
+
+ const result = await reinitMCPServer({
+ user,
+ serverName,
+ userMCPAuthMap,
+ });
+
+ if (!result) {
+ return res.status(500).json({ error: 'Failed to reinitialize MCP server for user' });
+ }
+
+ const { success, message, oauthRequired, oauthUrl } = result;
+
+ res.json({
+ success,
+ message,
+ oauthUrl,
+ serverName,
+ oauthRequired,
+ });
+ } catch (error) {
+ logger.error('[MCP Reinitialize] Unexpected error', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * Get connection status for all MCP servers
+ * This endpoint returns all app level and user-scoped connection statuses from MCPManager without disconnecting idle connections
+ */
+router.get('/connection/status', requireJwtAuth, async (req, res) => {
+ try {
+ const user = req.user;
+
+ if (!user?.id) {
+ return res.status(401).json({ error: 'User not authenticated' });
+ }
+
+ const { mcpConfig, appConnections, userConnections, oauthServers } = await getMCPSetupData(
+ user.id,
+ );
+ const connectionStatus = {};
+
+ for (const [serverName, config] of Object.entries(mcpConfig)) {
+ try {
+ connectionStatus[serverName] = await getServerConnectionStatus(
+ user.id,
+ serverName,
+ config,
+ appConnections,
+ userConnections,
+ oauthServers,
+ );
+ } catch (error) {
+ const message = `Failed to get status for server "${serverName}"`;
+ logger.error(`[MCP Connection Status] ${message},`, error);
+ connectionStatus[serverName] = {
+ connectionState: 'error',
+ requiresOAuth: oauthServers.has(serverName),
+ error: message,
+ };
+ }
+ }
+
+ res.json({
+ success: true,
+ connectionStatus,
+ });
+ } catch (error) {
+ if (error.message === 'MCP config not found') {
+ return res.status(404).json({ error: error.message });
+ }
+ logger.error('[MCP Connection Status] Failed to get connection status', error);
+ res.status(500).json({ error: 'Failed to get connection status' });
+ }
+});
+
+/**
+ * Get connection status for a single MCP server
+ * This endpoint returns the connection status for a specific server for a given user
+ */
+router.get('/connection/status/:serverName', requireJwtAuth, async (req, res) => {
+ try {
+ const user = req.user;
+ const { serverName } = req.params;
+
+ if (!user?.id) {
+ return res.status(401).json({ error: 'User not authenticated' });
+ }
+
+ const { mcpConfig, appConnections, userConnections, oauthServers } = await getMCPSetupData(
+ user.id,
+ );
+
+ if (!mcpConfig[serverName]) {
+ return res
+ .status(404)
+ .json({ error: `MCP server '${serverName}' not found in configuration` });
+ }
+
+ const serverStatus = await getServerConnectionStatus(
+ user.id,
+ serverName,
+ mcpConfig[serverName],
+ appConnections,
+ userConnections,
+ oauthServers,
+ );
+
+ res.json({
+ success: true,
+ serverName,
+ connectionStatus: serverStatus.connectionState,
+ requiresOAuth: serverStatus.requiresOAuth,
+ });
+ } catch (error) {
+ if (error.message === 'MCP config not found') {
+ return res.status(404).json({ error: error.message });
+ }
+ logger.error(
+ `[MCP Per-Server Status] Failed to get connection status for ${req.params.serverName}`,
+ error,
+ );
+ res.status(500).json({ error: 'Failed to get connection status' });
+ }
+});
+
+/**
+ * Check which authentication values exist for a specific MCP server
+ * This endpoint returns only boolean flags indicating if values are set, not the actual values
+ */
+router.get('/:serverName/auth-values', requireJwtAuth, async (req, res) => {
+ try {
+ const { serverName } = req.params;
+ const user = req.user;
+
+ if (!user?.id) {
+ return res.status(401).json({ error: 'User not authenticated' });
+ }
+
+ const serverConfig = await getMCPServersRegistry().getServerConfig(serverName, user.id);
+ if (!serverConfig) {
+ return res.status(404).json({
+ error: `MCP server '${serverName}' not found in configuration`,
+ });
+ }
+
+ const pluginKey = `${Constants.mcp_prefix}${serverName}`;
+ const authValueFlags = {};
+
+ if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
+ for (const varName of Object.keys(serverConfig.customUserVars)) {
+ try {
+ const value = await getUserPluginAuthValue(user.id, varName, false, pluginKey);
+ authValueFlags[varName] = !!(value && value.length > 0);
+ } catch (err) {
+ logger.error(
+ `[MCP Auth Value Flags] Error checking ${varName} for user ${user.id}:`,
+ err,
+ );
+ authValueFlags[varName] = false;
+ }
+ }
+ }
+
+ res.json({
+ success: true,
+ serverName,
+ authValueFlags,
+ });
+ } catch (error) {
+ logger.error(
+ `[MCP Auth Value Flags] Failed to check auth value flags for ${req.params.serverName}`,
+ error,
+ );
+ res.status(500).json({ error: 'Failed to check auth value flags' });
+ }
+});
+
+async function getOAuthHeaders(serverName, userId) {
+ const serverConfig = await getMCPServersRegistry().getServerConfig(serverName, userId);
+ return serverConfig?.oauth_headers ?? {};
+}
+
+/**
+MCP Server CRUD Routes (User-Managed MCP Servers)
+*/
+
+// Permission checkers for MCP server management
+const checkMCPUsePermissions = generateCheckAccess({
+ permissionType: PermissionTypes.MCP_SERVERS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+});
+
+const checkMCPCreate = generateCheckAccess({
+ permissionType: PermissionTypes.MCP_SERVERS,
+ permissions: [Permissions.USE, Permissions.CREATE],
+ getRoleByName,
+});
+
+/**
+ * Get list of accessible MCP servers
+ * @route GET /api/mcp/servers
+ * @param {Object} req.query - Query parameters for pagination and search
+ * @param {number} [req.query.limit] - Number of results per page
+ * @param {string} [req.query.after] - Pagination cursor
+ * @param {string} [req.query.search] - Search query for title/description
+ * @returns {MCPServerListResponse} 200 - Success response - application/json
+ */
+router.get('/servers', requireJwtAuth, checkMCPUsePermissions, getMCPServersList);
+
+/**
+ * Create a new MCP server
+ * @route POST /api/mcp/servers
+ * @param {MCPServerCreateParams} req.body - The MCP server creation parameters.
+ * @returns {MCPServer} 201 - Success response - application/json
+ */
+router.post('/servers', requireJwtAuth, checkMCPCreate, createMCPServerController);
+
+/**
+ * Get single MCP server by ID
+ * @route GET /api/mcp/servers/:serverName
+ * @param {string} req.params.serverName - MCP server identifier.
+ * @returns {MCPServer} 200 - Success response - application/json
+ */
+router.get(
+ '/servers/:serverName',
+ requireJwtAuth,
+ checkMCPUsePermissions,
+ canAccessMCPServerResource({
+ requiredPermission: PermissionBits.VIEW,
+ resourceIdParam: 'serverName',
+ }),
+ getMCPServerById,
+);
+
+/**
+ * Update MCP server
+ * @route PATCH /api/mcp/servers/:serverName
+ * @param {string} req.params.serverName - MCP server identifier.
+ * @param {MCPServerUpdateParams} req.body - The MCP server update parameters.
+ * @returns {MCPServer} 200 - Success response - application/json
+ */
+router.patch(
+ '/servers/:serverName',
+ requireJwtAuth,
+ checkMCPCreate,
+ canAccessMCPServerResource({
+ requiredPermission: PermissionBits.EDIT,
+ resourceIdParam: 'serverName',
+ }),
+ updateMCPServerController,
+);
+
+/**
+ * Delete MCP server
+ * @route DELETE /api/mcp/servers/:serverName
+ * @param {string} req.params.serverName - MCP server identifier.
+ * @returns {Object} 200 - Success response - application/json
+ */
+router.delete(
+ '/servers/:serverName',
+ requireJwtAuth,
+ checkMCPCreate,
+ canAccessMCPServerResource({
+ requiredPermission: PermissionBits.DELETE,
+ resourceIdParam: 'serverName',
+ }),
+ deleteMCPServerController,
+);
+
+module.exports = router;
diff --git a/api/server/routes/memories.js b/api/server/routes/memories.js
new file mode 100644
index 0000000000..58955d8ec4
--- /dev/null
+++ b/api/server/routes/memories.js
@@ -0,0 +1,298 @@
+const express = require('express');
+const { Tokenizer, generateCheckAccess } = require('@librechat/api');
+const { PermissionTypes, Permissions } = require('librechat-data-provider');
+const {
+ getAllUserMemories,
+ toggleUserMemories,
+ createMemory,
+ deleteMemory,
+ setMemory,
+} = require('~/models');
+const { requireJwtAuth, configMiddleware } = require('~/server/middleware');
+const { getRoleByName } = require('~/models/Role');
+
+const router = express.Router();
+
+const memoryPayloadLimit = express.json({ limit: '100kb' });
+
+const checkMemoryRead = generateCheckAccess({
+ permissionType: PermissionTypes.MEMORIES,
+ permissions: [Permissions.USE, Permissions.READ],
+ getRoleByName,
+});
+const checkMemoryCreate = generateCheckAccess({
+ permissionType: PermissionTypes.MEMORIES,
+ permissions: [Permissions.USE, Permissions.CREATE],
+ getRoleByName,
+});
+const checkMemoryUpdate = generateCheckAccess({
+ permissionType: PermissionTypes.MEMORIES,
+ permissions: [Permissions.USE, Permissions.UPDATE],
+ getRoleByName,
+});
+const checkMemoryDelete = generateCheckAccess({
+ permissionType: PermissionTypes.MEMORIES,
+ permissions: [Permissions.USE, Permissions.UPDATE],
+ getRoleByName,
+});
+const checkMemoryOptOut = generateCheckAccess({
+ permissionType: PermissionTypes.MEMORIES,
+ permissions: [Permissions.USE, Permissions.OPT_OUT],
+ getRoleByName,
+});
+
+router.use(requireJwtAuth);
+
+/**
+ * GET /memories
+ * Returns all memories for the authenticated user, sorted by updated_at (newest first).
+ * Also includes memory usage percentage based on token limit.
+ */
+router.get('/', checkMemoryRead, configMiddleware, async (req, res) => {
+ try {
+ const memories = await getAllUserMemories(req.user.id);
+
+ const sortedMemories = memories.sort(
+ (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
+ );
+
+ const totalTokens = memories.reduce((sum, memory) => {
+ return sum + (memory.tokenCount || 0);
+ }, 0);
+
+ const appConfig = req.config;
+ const memoryConfig = appConfig?.memory;
+ const tokenLimit = memoryConfig?.tokenLimit;
+ const charLimit = memoryConfig?.charLimit || 10000;
+
+ let usagePercentage = null;
+ if (tokenLimit && tokenLimit > 0) {
+ usagePercentage = Math.min(100, Math.round((totalTokens / tokenLimit) * 100));
+ }
+
+ res.json({
+ memories: sortedMemories,
+ totalTokens,
+ tokenLimit: tokenLimit || null,
+ charLimit,
+ usagePercentage,
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+/**
+ * POST /memories
+ * Creates a new memory entry for the authenticated user.
+ * Body: { key: string, value: string }
+ * Returns 201 and { created: true, memory: } when successful.
+ */
+router.post('/', memoryPayloadLimit, checkMemoryCreate, configMiddleware, async (req, res) => {
+ const { key, value } = req.body;
+
+ if (typeof key !== 'string' || key.trim() === '') {
+ return res.status(400).json({ error: 'Key is required and must be a non-empty string.' });
+ }
+
+ if (typeof value !== 'string' || value.trim() === '') {
+ return res.status(400).json({ error: 'Value is required and must be a non-empty string.' });
+ }
+
+ const appConfig = req.config;
+ const memoryConfig = appConfig?.memory;
+ const charLimit = memoryConfig?.charLimit || 10000;
+
+ if (key.length > 1000) {
+ return res.status(400).json({
+ error: `Key exceeds maximum length of 1000 characters. Current length: ${key.length} characters.`,
+ });
+ }
+
+ if (value.length > charLimit) {
+ return res.status(400).json({
+ error: `Value exceeds maximum length of ${charLimit} characters. Current length: ${value.length} characters.`,
+ });
+ }
+
+ try {
+ const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
+
+ const memories = await getAllUserMemories(req.user.id);
+
+ const appConfig = req.config;
+ const memoryConfig = appConfig?.memory;
+ const tokenLimit = memoryConfig?.tokenLimit;
+
+ if (tokenLimit) {
+ const currentTotalTokens = memories.reduce(
+ (sum, memory) => sum + (memory.tokenCount || 0),
+ 0,
+ );
+ if (currentTotalTokens + tokenCount > tokenLimit) {
+ return res.status(400).json({
+ error: `Adding this memory would exceed the token limit of ${tokenLimit}. Current usage: ${currentTotalTokens} tokens.`,
+ });
+ }
+ }
+
+ const result = await createMemory({
+ userId: req.user.id,
+ key: key.trim(),
+ value: value.trim(),
+ tokenCount,
+ });
+
+ if (!result.ok) {
+ return res.status(500).json({ error: 'Failed to create memory.' });
+ }
+
+ const updatedMemories = await getAllUserMemories(req.user.id);
+ const newMemory = updatedMemories.find((m) => m.key === key.trim());
+
+ res.status(201).json({ created: true, memory: newMemory });
+ } catch (error) {
+ if (error.message && error.message.includes('already exists')) {
+ return res.status(409).json({ error: 'Memory with this key already exists.' });
+ }
+ res.status(500).json({ error: error.message });
+ }
+});
+
+/**
+ * PATCH /memories/preferences
+ * Updates the user's memory preferences (e.g., enabling/disabling memories).
+ * Body: { memories: boolean }
+ * Returns 200 and { updated: true, preferences: { memories: boolean } } when successful.
+ */
+router.patch('/preferences', checkMemoryOptOut, async (req, res) => {
+ const { memories } = req.body;
+
+ if (typeof memories !== 'boolean') {
+ return res.status(400).json({ error: 'memories must be a boolean value.' });
+ }
+
+ try {
+ const updatedUser = await toggleUserMemories(req.user.id, memories);
+
+ if (!updatedUser) {
+ return res.status(404).json({ error: 'User not found.' });
+ }
+
+ res.json({
+ updated: true,
+ preferences: {
+ memories: updatedUser.personalization?.memories ?? true,
+ },
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+/**
+ * PATCH /memories/:key
+ * Updates the value of an existing memory entry for the authenticated user.
+ * Body: { key?: string, value: string }
+ * Returns 200 and { updated: true, memory: } when successful.
+ */
+router.patch('/:key', memoryPayloadLimit, checkMemoryUpdate, configMiddleware, async (req, res) => {
+ const { key: urlKey } = req.params;
+ const { key: bodyKey, value } = req.body || {};
+
+ if (typeof value !== 'string' || value.trim() === '') {
+ return res.status(400).json({ error: 'Value is required and must be a non-empty string.' });
+ }
+
+ const newKey = bodyKey || urlKey;
+ const appConfig = req.config;
+ const memoryConfig = appConfig?.memory;
+ const charLimit = memoryConfig?.charLimit || 10000;
+
+ if (newKey.length > 1000) {
+ return res.status(400).json({
+ error: `Key exceeds maximum length of 1000 characters. Current length: ${newKey.length} characters.`,
+ });
+ }
+
+ if (value.length > charLimit) {
+ return res.status(400).json({
+ error: `Value exceeds maximum length of ${charLimit} characters. Current length: ${value.length} characters.`,
+ });
+ }
+
+ try {
+ const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
+
+ const memories = await getAllUserMemories(req.user.id);
+ const existingMemory = memories.find((m) => m.key === urlKey);
+
+ if (!existingMemory) {
+ return res.status(404).json({ error: 'Memory not found.' });
+ }
+
+ if (newKey !== urlKey) {
+ const keyExists = memories.find((m) => m.key === newKey);
+ if (keyExists) {
+ return res.status(409).json({ error: 'Memory with this key already exists.' });
+ }
+
+ const createResult = await createMemory({
+ userId: req.user.id,
+ key: newKey,
+ value,
+ tokenCount,
+ });
+
+ if (!createResult.ok) {
+ return res.status(500).json({ error: 'Failed to create new memory.' });
+ }
+
+ const deleteResult = await deleteMemory({ userId: req.user.id, key: urlKey });
+ if (!deleteResult.ok) {
+ return res.status(500).json({ error: 'Failed to delete old memory.' });
+ }
+ } else {
+ const result = await setMemory({
+ userId: req.user.id,
+ key: newKey,
+ value,
+ tokenCount,
+ });
+
+ if (!result.ok) {
+ return res.status(500).json({ error: 'Failed to update memory.' });
+ }
+ }
+
+ const updatedMemories = await getAllUserMemories(req.user.id);
+ const updatedMemory = updatedMemories.find((m) => m.key === newKey);
+
+ res.json({ updated: true, memory: updatedMemory });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+/**
+ * DELETE /memories/:key
+ * Deletes a memory entry for the authenticated user.
+ * Returns 200 and { deleted: true } when successful.
+ */
+router.delete('/:key', checkMemoryDelete, async (req, res) => {
+ const { key } = req.params;
+
+ try {
+ const result = await deleteMemory({ userId: req.user.id, key });
+
+ if (!result.ok) {
+ return res.status(404).json({ error: 'Memory not found.' });
+ }
+
+ res.json({ deleted: true });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js
index be34a062ef..0438edb933 100644
--- a/api/server/routes/messages.js
+++ b/api/server/routes/messages.js
@@ -1,10 +1,325 @@
const express = require('express');
-const router = express.Router();
-const { getMessages } = require('../../models/Message');
+const { logger } = require('@librechat/data-schemas');
+const { ContentTypes } = require('librechat-data-provider');
+const { unescapeLaTeX, countTokens } = require('@librechat/api');
+const {
+ saveConvo,
+ getMessage,
+ saveMessage,
+ getMessages,
+ updateMessage,
+ deleteMessages,
+} = require('~/models');
+const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/Artifacts/update');
+const { requireJwtAuth, validateMessageReq } = require('~/server/middleware');
+const { getConvosQueried } = require('~/models/Conversation');
+const { Message } = require('~/db/models');
-router.get('/:conversationId', async (req, res) => {
- const { conversationId } = req.params;
- res.status(200).send(await getMessages({ conversationId }));
+const router = express.Router();
+router.use(requireJwtAuth);
+
+router.get('/', async (req, res) => {
+ try {
+ const user = req.user.id ?? '';
+ const {
+ cursor = null,
+ sortBy = 'createdAt',
+ sortDirection = 'desc',
+ pageSize: pageSizeRaw,
+ conversationId,
+ messageId,
+ search,
+ } = req.query;
+ const pageSize = parseInt(pageSizeRaw, 10) || 25;
+
+ let response;
+ const sortField = ['endpoint', 'createdAt', 'updatedAt'].includes(sortBy)
+ ? sortBy
+ : 'createdAt';
+ const sortOrder = sortDirection === 'asc' ? 1 : -1;
+
+ if (conversationId && messageId) {
+ const message = await Message.findOne({
+ conversationId,
+ messageId,
+ user: user,
+ }).lean();
+ response = { messages: message ? [message] : [], nextCursor: null };
+ } else if (conversationId) {
+ const filter = { conversationId, user: user };
+ if (cursor) {
+ filter[sortField] = sortOrder === 1 ? { $gt: cursor } : { $lt: cursor };
+ }
+ const messages = await Message.find(filter)
+ .sort({ [sortField]: sortOrder })
+ .limit(pageSize + 1)
+ .lean();
+ const nextCursor = messages.length > pageSize ? messages.pop()[sortField] : null;
+ response = { messages, nextCursor };
+ } else if (search) {
+ const searchResults = await Message.meiliSearch(search, { filter: `user = "${user}"` }, true);
+
+ const messages = searchResults.hits || [];
+
+ const result = await getConvosQueried(req.user.id, messages, cursor);
+
+ const messageIds = [];
+ const cleanedMessages = [];
+ for (let i = 0; i < messages.length; i++) {
+ let message = messages[i];
+ if (result.convoMap[message.conversationId]) {
+ messageIds.push(message.messageId);
+ cleanedMessages.push(message);
+ }
+ }
+
+ const dbMessages = await getMessages({
+ user,
+ messageId: { $in: messageIds },
+ });
+
+ const dbMessageMap = {};
+ for (const dbMessage of dbMessages) {
+ dbMessageMap[dbMessage.messageId] = dbMessage;
+ }
+
+ const activeMessages = [];
+ for (const message of cleanedMessages) {
+ const convo = result.convoMap[message.conversationId];
+ const dbMessage = dbMessageMap[message.messageId];
+
+ activeMessages.push({
+ ...message,
+ title: convo.title,
+ conversationId: message.conversationId,
+ model: convo.model,
+ isCreatedByUser: dbMessage?.isCreatedByUser,
+ endpoint: dbMessage?.endpoint,
+ iconURL: dbMessage?.iconURL,
+ });
+ }
+
+ response = { messages: activeMessages, nextCursor: null };
+ } else {
+ response = { messages: [], nextCursor: null };
+ }
+
+ res.status(200).json(response);
+ } catch (error) {
+ logger.error('Error fetching messages:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+router.post('/artifact/:messageId', async (req, res) => {
+ try {
+ const { messageId } = req.params;
+ const { index, original, updated } = req.body;
+
+ if (typeof index !== 'number' || index < 0 || original == null || updated == null) {
+ return res.status(400).json({ error: 'Invalid request parameters' });
+ }
+
+ const message = await getMessage({ user: req.user.id, messageId });
+ if (!message) {
+ return res.status(404).json({ error: 'Message not found' });
+ }
+
+ const artifacts = findAllArtifacts(message);
+ if (index >= artifacts.length) {
+ return res.status(400).json({ error: 'Artifact index out of bounds' });
+ }
+
+ // Unescape LaTeX preprocessing done by the frontend
+ // The frontend escapes $ signs for display, but the database has unescaped versions
+ const unescapedOriginal = unescapeLaTeX(original);
+ const unescapedUpdated = unescapeLaTeX(updated);
+
+ const targetArtifact = artifacts[index];
+ let updatedText = null;
+
+ if (targetArtifact.source === 'content') {
+ const part = message.content[targetArtifact.partIndex];
+ updatedText = replaceArtifactContent(
+ part.text,
+ targetArtifact,
+ unescapedOriginal,
+ unescapedUpdated,
+ );
+ if (updatedText) {
+ part.text = updatedText;
+ }
+ } else {
+ updatedText = replaceArtifactContent(
+ message.text,
+ targetArtifact,
+ unescapedOriginal,
+ unescapedUpdated,
+ );
+ if (updatedText) {
+ message.text = updatedText;
+ }
+ }
+
+ if (!updatedText) {
+ return res.status(400).json({ error: 'Original content not found in target artifact' });
+ }
+
+ const savedMessage = await saveMessage(
+ req,
+ {
+ messageId,
+ conversationId: message.conversationId,
+ text: message.text,
+ content: message.content,
+ user: req.user.id,
+ },
+ { context: 'POST /api/messages/artifact/:messageId' },
+ );
+
+ res.status(200).json({
+ conversationId: savedMessage.conversationId,
+ content: savedMessage.content,
+ text: savedMessage.text,
+ });
+ } catch (error) {
+ logger.error('Error editing artifact:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/* Note: It's necessary to add `validateMessageReq` within route definition for correct params */
+router.get('/:conversationId', validateMessageReq, async (req, res) => {
+ try {
+ const { conversationId } = req.params;
+ const messages = await getMessages({ conversationId }, '-_id -__v -user');
+ res.status(200).json(messages);
+ } catch (error) {
+ logger.error('Error fetching messages:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+router.post('/:conversationId', validateMessageReq, async (req, res) => {
+ try {
+ const message = req.body;
+ const savedMessage = await saveMessage(
+ req,
+ { ...message, user: req.user.id },
+ { context: 'POST /api/messages/:conversationId' },
+ );
+ if (!savedMessage) {
+ return res.status(400).json({ error: 'Message not saved' });
+ }
+ await saveConvo(req, savedMessage, { context: 'POST /api/messages/:conversationId' });
+ res.status(201).json(savedMessage);
+ } catch (error) {
+ logger.error('Error saving message:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+router.get('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
+ try {
+ const { conversationId, messageId } = req.params;
+ const message = await getMessages({ conversationId, messageId }, '-_id -__v -user');
+ if (!message) {
+ return res.status(404).json({ error: 'Message not found' });
+ }
+ res.status(200).json(message);
+ } catch (error) {
+ logger.error('Error fetching message:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+router.put('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
+ try {
+ const { conversationId, messageId } = req.params;
+ const { text, index, model } = req.body;
+
+ if (index === undefined) {
+ const tokenCount = await countTokens(text, model);
+ const result = await updateMessage(req, { messageId, text, tokenCount });
+ return res.status(200).json(result);
+ }
+
+ if (typeof index !== 'number' || index < 0) {
+ return res.status(400).json({ error: 'Invalid index' });
+ }
+
+ const message = (await getMessages({ conversationId, messageId }, 'content tokenCount'))?.[0];
+ if (!message) {
+ return res.status(404).json({ error: 'Message not found' });
+ }
+
+ const existingContent = message.content;
+ if (!Array.isArray(existingContent) || index >= existingContent.length) {
+ return res.status(400).json({ error: 'Invalid index' });
+ }
+
+ const updatedContent = [...existingContent];
+ if (!updatedContent[index]) {
+ return res.status(400).json({ error: 'Content part not found' });
+ }
+
+ const currentPartType = updatedContent[index].type;
+ if (currentPartType !== ContentTypes.TEXT && currentPartType !== ContentTypes.THINK) {
+ return res.status(400).json({ error: 'Cannot update non-text content' });
+ }
+
+ const oldText = updatedContent[index][currentPartType];
+ updatedContent[index] = { type: currentPartType, [currentPartType]: text };
+
+ let tokenCount = message.tokenCount;
+ if (tokenCount !== undefined) {
+ const oldTokenCount = await countTokens(oldText, model);
+ const newTokenCount = await countTokens(text, model);
+ tokenCount = Math.max(0, tokenCount - oldTokenCount) + newTokenCount;
+ }
+
+ const result = await updateMessage(req, { messageId, content: updatedContent, tokenCount });
+ return res.status(200).json(result);
+ } catch (error) {
+ logger.error('Error updating message:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+router.put('/:conversationId/:messageId/feedback', validateMessageReq, async (req, res) => {
+ try {
+ const { conversationId, messageId } = req.params;
+ const { feedback } = req.body;
+
+ const updatedMessage = await updateMessage(
+ req,
+ {
+ messageId,
+ feedback: feedback || null,
+ },
+ { context: 'updateFeedback' },
+ );
+
+ res.json({
+ messageId,
+ conversationId,
+ feedback: updatedMessage.feedback,
+ });
+ } catch (error) {
+ logger.error('Error updating message feedback:', error);
+ res.status(500).json({ error: 'Failed to update feedback' });
+ }
+});
+
+router.delete('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
+ try {
+ const { messageId } = req.params;
+ await deleteMessages({ messageId });
+ res.status(204).send();
+ } catch (error) {
+ logger.error('Error deleting message:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
});
module.exports = router;
diff --git a/api/server/routes/models.js b/api/server/routes/models.js
new file mode 100644
index 0000000000..e3272087a7
--- /dev/null
+++ b/api/server/routes/models.js
@@ -0,0 +1,8 @@
+const express = require('express');
+const { modelController } = require('~/server/controllers/ModelController');
+const { requireJwtAuth } = require('~/server/middleware/');
+
+const router = express.Router();
+router.get('/', requireJwtAuth, modelController);
+
+module.exports = router;
diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js
new file mode 100644
index 0000000000..0b1252f636
--- /dev/null
+++ b/api/server/routes/oauth.js
@@ -0,0 +1,228 @@
+// file deepcode ignore NoRateLimitingForLogin: Rate limiting is handled by the `loginLimiter` middleware
+const express = require('express');
+const passport = require('passport');
+const { randomState } = require('openid-client');
+const { logger } = require('@librechat/data-schemas');
+const { ErrorTypes } = require('librechat-data-provider');
+const { isEnabled, createSetBalanceConfig } = require('@librechat/api');
+const { checkDomainAllowed, loginLimiter, logHeaders, checkBan } = require('~/server/middleware');
+const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
+const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
+const { getAppConfig } = require('~/server/services/Config');
+const { Balance } = require('~/db/models');
+
+const setBalanceConfig = createSetBalanceConfig({
+ getAppConfig,
+ Balance,
+});
+
+const router = express.Router();
+
+const domains = {
+ client: process.env.DOMAIN_CLIENT,
+ server: process.env.DOMAIN_SERVER,
+};
+
+router.use(logHeaders);
+router.use(loginLimiter);
+
+const oauthHandler = async (req, res, next) => {
+ try {
+ if (res.headersSent) {
+ return;
+ }
+
+ await checkBan(req, res);
+ if (req.banned) {
+ return;
+ }
+ if (
+ req.user &&
+ req.user.provider == 'openid' &&
+ isEnabled(process.env.OPENID_REUSE_TOKENS) === true
+ ) {
+ await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
+ setOpenIDAuthTokens(req.user.tokenset, res, req.user._id.toString());
+ } else {
+ await setAuthTokens(req.user._id, res);
+ }
+ res.redirect(domains.client);
+ } catch (err) {
+ logger.error('Error in setting authentication tokens:', err);
+ next(err);
+ }
+};
+
+router.get('/error', (req, res) => {
+ /** A single error message is pushed by passport when authentication fails. */
+ const errorMessage = req.session?.messages?.pop() || 'Unknown error';
+ logger.error('Error in OAuth authentication:', {
+ message: errorMessage,
+ });
+
+ res.redirect(`${domains.client}/login?redirect=false&error=${ErrorTypes.AUTH_FAILED}`);
+});
+
+/**
+ * Google Routes
+ */
+router.get(
+ '/google',
+ passport.authenticate('google', {
+ scope: ['openid', 'profile', 'email'],
+ session: false,
+ }),
+);
+
+router.get(
+ '/google/callback',
+ passport.authenticate('google', {
+ failureRedirect: `${domains.client}/oauth/error`,
+ failureMessage: true,
+ session: false,
+ scope: ['openid', 'profile', 'email'],
+ }),
+ setBalanceConfig,
+ checkDomainAllowed,
+ oauthHandler,
+);
+
+/**
+ * Facebook Routes
+ */
+router.get(
+ '/facebook',
+ passport.authenticate('facebook', {
+ scope: ['public_profile'],
+ profileFields: ['id', 'email', 'name'],
+ session: false,
+ }),
+);
+
+router.get(
+ '/facebook/callback',
+ passport.authenticate('facebook', {
+ failureRedirect: `${domains.client}/oauth/error`,
+ failureMessage: true,
+ session: false,
+ scope: ['public_profile'],
+ profileFields: ['id', 'email', 'name'],
+ }),
+ setBalanceConfig,
+ checkDomainAllowed,
+ oauthHandler,
+);
+
+/**
+ * OpenID Routes
+ */
+router.get('/openid', (req, res, next) => {
+ return passport.authenticate('openid', {
+ session: false,
+ state: randomState(),
+ })(req, res, next);
+});
+
+router.get(
+ '/openid/callback',
+ passport.authenticate('openid', {
+ failureRedirect: `${domains.client}/oauth/error`,
+ failureMessage: true,
+ session: false,
+ }),
+ setBalanceConfig,
+ checkDomainAllowed,
+ oauthHandler,
+);
+
+/**
+ * GitHub Routes
+ */
+router.get(
+ '/github',
+ passport.authenticate('github', {
+ scope: ['user:email', 'read:user'],
+ session: false,
+ }),
+);
+
+router.get(
+ '/github/callback',
+ passport.authenticate('github', {
+ failureRedirect: `${domains.client}/oauth/error`,
+ failureMessage: true,
+ session: false,
+ scope: ['user:email', 'read:user'],
+ }),
+ setBalanceConfig,
+ checkDomainAllowed,
+ oauthHandler,
+);
+
+/**
+ * Discord Routes
+ */
+router.get(
+ '/discord',
+ passport.authenticate('discord', {
+ scope: ['identify', 'email'],
+ session: false,
+ }),
+);
+
+router.get(
+ '/discord/callback',
+ passport.authenticate('discord', {
+ failureRedirect: `${domains.client}/oauth/error`,
+ failureMessage: true,
+ session: false,
+ scope: ['identify', 'email'],
+ }),
+ setBalanceConfig,
+ checkDomainAllowed,
+ oauthHandler,
+);
+
+/**
+ * Apple Routes
+ */
+router.get(
+ '/apple',
+ passport.authenticate('apple', {
+ session: false,
+ }),
+);
+
+router.post(
+ '/apple/callback',
+ passport.authenticate('apple', {
+ failureRedirect: `${domains.client}/oauth/error`,
+ failureMessage: true,
+ session: false,
+ }),
+ setBalanceConfig,
+ checkDomainAllowed,
+ oauthHandler,
+);
+
+/**
+ * SAML Routes
+ */
+router.get(
+ '/saml',
+ passport.authenticate('saml', {
+ session: false,
+ }),
+);
+
+router.post(
+ '/saml/callback',
+ passport.authenticate('saml', {
+ failureRedirect: `${domains.client}/oauth/error`,
+ failureMessage: true,
+ session: false,
+ }),
+ oauthHandler,
+);
+
+module.exports = router;
diff --git a/api/server/routes/presets.js b/api/server/routes/presets.js
new file mode 100644
index 0000000000..b8be1b8f3a
--- /dev/null
+++ b/api/server/routes/presets.js
@@ -0,0 +1,48 @@
+const crypto = require('crypto');
+const express = require('express');
+const { logger } = require('@librechat/data-schemas');
+const { getPresets, savePreset, deletePresets } = require('~/models');
+const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
+
+const router = express.Router();
+router.use(requireJwtAuth);
+
+router.get('/', async (req, res) => {
+ const presets = (await getPresets(req.user.id)).map((preset) => preset);
+ res.status(200).json(presets);
+});
+
+router.post('/', async (req, res) => {
+ const update = req.body || {};
+
+ update.presetId = update?.presetId || crypto.randomUUID();
+
+ try {
+ const preset = await savePreset(req.user.id, update);
+ res.status(201).json(preset);
+ } catch (error) {
+ logger.error('[/presets] error saving preset', error);
+ res.status(500).send('There was an error when saving the preset');
+ }
+});
+
+router.post('/delete', async (req, res) => {
+ let filter = {};
+ const { presetId } = req.body || {};
+
+ if (presetId) {
+ filter = { presetId };
+ }
+
+ logger.debug('[/presets/delete] delete preset filter', filter);
+
+ try {
+ const deleteCount = await deletePresets(req.user.id, filter);
+ res.status(201).json(deleteCount);
+ } catch (error) {
+ logger.error('[/presets/delete] error deleting presets', error);
+ res.status(500).send('There was an error deleting the presets');
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/prompts.js b/api/server/routes/prompts.js
index 84ff935f00..c833719075 100644
--- a/api/server/routes/prompts.js
+++ b/api/server/routes/prompts.js
@@ -1,14 +1,503 @@
const express = require('express');
-const router = express.Router();
-const { savePrompt, getPrompts, deletePrompts } = require('../../models/Prompt');
+const { logger } = require('@librechat/data-schemas');
+const {
+ generateCheckAccess,
+ markPublicPromptGroups,
+ buildPromptGroupFilter,
+ formatPromptGroupsResponse,
+ safeValidatePromptGroupUpdate,
+ createEmptyPromptGroupsResponse,
+ filterAccessibleIdsBySharedLogic,
+} = require('@librechat/api');
+const {
+ Permissions,
+ SystemRoles,
+ ResourceType,
+ AccessRoleIds,
+ PrincipalType,
+ PermissionBits,
+ PermissionTypes,
+} = require('librechat-data-provider');
+const {
+ getListPromptGroupsByAccess,
+ makePromptProduction,
+ updatePromptGroup,
+ deletePromptGroup,
+ createPromptGroup,
+ getPromptGroup,
+ deletePrompt,
+ getPrompts,
+ savePrompt,
+ getPrompt,
+} = require('~/models/Prompt');
+const {
+ canAccessPromptGroupResource,
+ canAccessPromptViaGroup,
+ requireJwtAuth,
+} = require('~/server/middleware');
+const {
+ findPubliclyAccessibleResources,
+ getEffectivePermissions,
+ findAccessibleResources,
+ grantPermission,
+} = require('~/server/services/PermissionService');
+const { getRoleByName } = require('~/models/Role');
-router.get('/', async (req, res) => {
- let filter = {};
- // const { search } = req.body.arg;
- // if (!!search) {
- // filter = { conversationId };
- // }
- res.status(200).send(await getPrompts(filter));
+const router = express.Router();
+
+const checkPromptAccess = generateCheckAccess({
+ permissionType: PermissionTypes.PROMPTS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+});
+const checkPromptCreate = generateCheckAccess({
+ permissionType: PermissionTypes.PROMPTS,
+ permissions: [Permissions.USE, Permissions.CREATE],
+ getRoleByName,
});
+const checkGlobalPromptShare = generateCheckAccess({
+ permissionType: PermissionTypes.PROMPTS,
+ permissions: [Permissions.USE, Permissions.CREATE],
+ bodyProps: {
+ [Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'],
+ },
+ getRoleByName,
+});
+
+router.use(requireJwtAuth);
+router.use(checkPromptAccess);
+
+/**
+ * Route to get single prompt group by its ID
+ * GET /groups/:groupId
+ */
+router.get(
+ '/groups/:groupId',
+ canAccessPromptGroupResource({
+ requiredPermission: PermissionBits.VIEW,
+ }),
+ async (req, res) => {
+ const { groupId } = req.params;
+
+ try {
+ const group = await getPromptGroup({ _id: groupId });
+
+ if (!group) {
+ return res.status(404).send({ message: 'Prompt group not found' });
+ }
+
+ res.status(200).send(group);
+ } catch (error) {
+ logger.error('Error getting prompt group', error);
+ res.status(500).send({ message: 'Error getting prompt group' });
+ }
+ },
+);
+
+/**
+ * Route to fetch all prompt groups (ACL-aware)
+ * GET /all
+ */
+router.get('/all', async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const { name, category, ...otherFilters } = req.query;
+ const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({
+ name,
+ category,
+ ...otherFilters,
+ });
+
+ let accessibleIds = await findAccessibleResources({
+ userId,
+ role: req.user.role,
+ resourceType: ResourceType.PROMPTGROUP,
+ requiredPermissions: PermissionBits.VIEW,
+ });
+
+ const publiclyAccessibleIds = await findPubliclyAccessibleResources({
+ resourceType: ResourceType.PROMPTGROUP,
+ requiredPermissions: PermissionBits.VIEW,
+ });
+
+ const filteredAccessibleIds = await filterAccessibleIdsBySharedLogic({
+ accessibleIds,
+ searchShared,
+ searchSharedOnly,
+ publicPromptGroupIds: publiclyAccessibleIds,
+ });
+
+ const result = await getListPromptGroupsByAccess({
+ accessibleIds: filteredAccessibleIds,
+ otherParams: filter,
+ });
+
+ if (!result) {
+ return res.status(200).send([]);
+ }
+
+ const { data: promptGroups = [] } = result;
+ if (!promptGroups.length) {
+ return res.status(200).send([]);
+ }
+
+ const groupsWithPublicFlag = markPublicPromptGroups(promptGroups, publiclyAccessibleIds);
+ res.status(200).send(groupsWithPublicFlag);
+ } catch (error) {
+ logger.error(error);
+ res.status(500).send({ error: 'Error getting prompt groups' });
+ }
+});
+
+/**
+ * Route to fetch paginated prompt groups with filters (ACL-aware)
+ * GET /groups
+ */
+router.get('/groups', async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const { pageSize, limit, cursor, name, category, ...otherFilters } = req.query;
+
+ const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({
+ name,
+ category,
+ ...otherFilters,
+ });
+
+ let actualLimit = limit;
+ let actualCursor = cursor;
+
+ if (pageSize && !limit) {
+ actualLimit = parseInt(pageSize, 10);
+ }
+
+ if (
+ actualCursor &&
+ (actualCursor === 'undefined' || actualCursor === 'null' || actualCursor.length === 0)
+ ) {
+ actualCursor = null;
+ }
+
+ let accessibleIds = await findAccessibleResources({
+ userId,
+ role: req.user.role,
+ resourceType: ResourceType.PROMPTGROUP,
+ requiredPermissions: PermissionBits.VIEW,
+ });
+
+ const publiclyAccessibleIds = await findPubliclyAccessibleResources({
+ resourceType: ResourceType.PROMPTGROUP,
+ requiredPermissions: PermissionBits.VIEW,
+ });
+
+ const filteredAccessibleIds = await filterAccessibleIdsBySharedLogic({
+ accessibleIds,
+ searchShared,
+ searchSharedOnly,
+ publicPromptGroupIds: publiclyAccessibleIds,
+ });
+
+ // Cursor-based pagination only
+ const result = await getListPromptGroupsByAccess({
+ accessibleIds: filteredAccessibleIds,
+ otherParams: filter,
+ limit: actualLimit,
+ after: actualCursor,
+ });
+
+ if (!result) {
+ const emptyResponse = createEmptyPromptGroupsResponse({
+ pageNumber: '1',
+ pageSize: actualLimit,
+ actualLimit,
+ });
+ return res.status(200).send(emptyResponse);
+ }
+
+ const { data: promptGroups = [], has_more = false, after = null } = result;
+ const groupsWithPublicFlag = markPublicPromptGroups(promptGroups, publiclyAccessibleIds);
+
+ const response = formatPromptGroupsResponse({
+ promptGroups: groupsWithPublicFlag,
+ pageNumber: '1', // Always 1 for cursor-based pagination
+ pageSize: actualLimit.toString(),
+ hasMore: has_more,
+ after,
+ });
+
+ res.status(200).send(response);
+ } catch (error) {
+ logger.error(error);
+ res.status(500).send({ error: 'Error getting prompt groups' });
+ }
+});
+
+/**
+ * Creates a new prompt group with initial prompt
+ * @param {object} req
+ * @param {TCreatePrompt} req.body
+ * @param {Express.Response} res
+ */
+const createNewPromptGroup = async (req, res) => {
+ try {
+ const { prompt, group } = req.body;
+
+ if (!prompt || !group || !group.name) {
+ return res.status(400).send({ error: 'Prompt and group name are required' });
+ }
+
+ const saveData = {
+ prompt,
+ group,
+ author: req.user.id,
+ authorName: req.user.name,
+ };
+
+ const result = await createPromptGroup(saveData);
+
+ if (result.prompt && result.prompt._id && result.prompt.groupId) {
+ try {
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: req.user.id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: result.prompt.groupId,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: req.user.id,
+ });
+ logger.debug(
+ `[createPromptGroup] Granted owner permissions to user ${req.user.id} for promptGroup ${result.prompt.groupId}`,
+ );
+ } catch (permissionError) {
+ logger.error(
+ `[createPromptGroup] Failed to grant owner permissions for promptGroup ${result.prompt.groupId}:`,
+ permissionError,
+ );
+ }
+ }
+
+ res.status(200).send(result);
+ } catch (error) {
+ logger.error(error);
+ res.status(500).send({ error: 'Error creating prompt group' });
+ }
+};
+
+/**
+ * Adds a new prompt to an existing prompt group
+ * @param {object} req
+ * @param {TCreatePrompt} req.body
+ * @param {Express.Response} res
+ */
+const addPromptToGroup = async (req, res) => {
+ try {
+ const { groupId } = req.params;
+ const { prompt } = req.body;
+
+ if (!prompt) {
+ return res.status(400).send({ error: 'Prompt is required' });
+ }
+
+ // Ensure the prompt is associated with the correct group
+ prompt.groupId = groupId;
+
+ const saveData = {
+ prompt,
+ author: req.user.id,
+ authorName: req.user.name,
+ };
+
+ const result = await savePrompt(saveData);
+ res.status(200).send(result);
+ } catch (error) {
+ logger.error(error);
+ res.status(500).send({ error: 'Error adding prompt to group' });
+ }
+};
+
+// Create new prompt group (requires CREATE permission)
+router.post('/', checkPromptCreate, createNewPromptGroup);
+
+// Add prompt to existing group (requires EDIT permission on the group)
+router.post(
+ '/groups/:groupId/prompts',
+ checkPromptAccess,
+ canAccessPromptGroupResource({
+ requiredPermission: PermissionBits.EDIT,
+ }),
+ addPromptToGroup,
+);
+
+/**
+ * Updates a prompt group
+ * @param {object} req
+ * @param {object} req.params - The request parameters
+ * @param {string} req.params.groupId - The group ID
+ * @param {TUpdatePromptGroupPayload} req.body - The request body
+ * @param {Express.Response} res
+ */
+const patchPromptGroup = async (req, res) => {
+ try {
+ const { groupId } = req.params;
+ const author = req.user.id;
+ const filter = { _id: groupId, author };
+ if (req.user.role === SystemRoles.ADMIN) {
+ delete filter.author;
+ }
+
+ const validationResult = safeValidatePromptGroupUpdate(req.body);
+ if (!validationResult.success) {
+ return res.status(400).send({
+ error: 'Invalid request body',
+ details: validationResult.error.errors,
+ });
+ }
+
+ const promptGroup = await updatePromptGroup(filter, validationResult.data);
+ res.status(200).send(promptGroup);
+ } catch (error) {
+ logger.error(error);
+ res.status(500).send({ error: 'Error updating prompt group' });
+ }
+};
+
+router.patch(
+ '/groups/:groupId',
+ checkGlobalPromptShare,
+ canAccessPromptGroupResource({
+ requiredPermission: PermissionBits.EDIT,
+ }),
+ patchPromptGroup,
+);
+
+router.patch(
+ '/:promptId/tags/production',
+ checkPromptCreate,
+ canAccessPromptViaGroup({
+ requiredPermission: PermissionBits.EDIT,
+ resourceIdParam: 'promptId',
+ }),
+ async (req, res) => {
+ try {
+ const { promptId } = req.params;
+ const result = await makePromptProduction(promptId);
+ res.status(200).send(result);
+ } catch (error) {
+ logger.error(error);
+ res.status(500).send({ error: 'Error updating prompt production' });
+ }
+ },
+);
+
+router.get(
+ '/:promptId',
+ canAccessPromptViaGroup({
+ requiredPermission: PermissionBits.VIEW,
+ resourceIdParam: 'promptId',
+ }),
+ async (req, res) => {
+ const { promptId } = req.params;
+ const prompt = await getPrompt({ _id: promptId });
+ res.status(200).send(prompt);
+ },
+);
+
+router.get('/', async (req, res) => {
+ try {
+ const author = req.user.id;
+ const { groupId } = req.query;
+
+ // If requesting prompts for a specific group, check permissions
+ if (groupId) {
+ const permissions = await getEffectivePermissions({
+ userId: req.user.id,
+ role: req.user.role,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: groupId,
+ });
+
+ if (!(permissions & PermissionBits.VIEW)) {
+ return res
+ .status(403)
+ .send({ error: 'Insufficient permissions to view prompts in this group' });
+ }
+
+ // If user has access, fetch all prompts in the group (not just their own)
+ const prompts = await getPrompts({ groupId });
+ return res.status(200).send(prompts);
+ }
+
+ // If no groupId, return user's own prompts
+ const query = { author };
+ if (req.user.role === SystemRoles.ADMIN) {
+ delete query.author;
+ }
+ const prompts = await getPrompts(query);
+ res.status(200).send(prompts);
+ } catch (error) {
+ logger.error(error);
+ res.status(500).send({ error: 'Error getting prompts' });
+ }
+});
+
+/**
+ * Deletes a prompt
+ *
+ * @param {ServerRequest} req - The request object.
+ * @param {TDeletePromptVariables} req.params - The request parameters
+ * @param {import('mongoose').ObjectId} req.params.promptId - The prompt ID
+ * @param {Express.Response} res - The response object.
+ * @return {TDeletePromptResponse} A promise that resolves when the prompt is deleted.
+ */
+const deletePromptController = async (req, res) => {
+ try {
+ const { promptId } = req.params;
+ const { groupId } = req.query;
+ const author = req.user.id;
+ const query = { promptId, groupId, author, role: req.user.role };
+ const result = await deletePrompt(query);
+ res.status(200).send(result);
+ } catch (error) {
+ logger.error(error);
+ res.status(500).send({ error: 'Error deleting prompt' });
+ }
+};
+
+/**
+ * Delete a prompt group
+ * @param {ServerRequest} req
+ * @param {ServerResponse} res
+ * @returns {Promise}
+ */
+const deletePromptGroupController = async (req, res) => {
+ try {
+ const { groupId: _id } = req.params;
+ // Don't pass author - permissions are now checked by middleware
+ const message = await deletePromptGroup({ _id, role: req.user.role });
+ res.send(message);
+ } catch (error) {
+ logger.error('Error deleting prompt group', error);
+ res.status(500).send({ message: 'Error deleting prompt group' });
+ }
+};
+
+router.delete(
+ '/:promptId',
+ checkPromptCreate,
+ canAccessPromptViaGroup({
+ requiredPermission: PermissionBits.DELETE,
+ resourceIdParam: 'promptId',
+ }),
+ deletePromptController,
+);
+router.delete(
+ '/groups/:groupId',
+ checkPromptCreate,
+ canAccessPromptGroupResource({
+ requiredPermission: PermissionBits.DELETE,
+ }),
+ deletePromptGroupController,
+);
+
module.exports = router;
diff --git a/api/server/routes/prompts.test.js b/api/server/routes/prompts.test.js
new file mode 100644
index 0000000000..1aeca1c93c
--- /dev/null
+++ b/api/server/routes/prompts.test.js
@@ -0,0 +1,1010 @@
+const express = require('express');
+const request = require('supertest');
+const mongoose = require('mongoose');
+const { ObjectId } = require('mongodb');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const {
+ SystemRoles,
+ ResourceType,
+ AccessRoleIds,
+ PrincipalType,
+ PermissionBits,
+} = require('librechat-data-provider');
+
+// Mock modules before importing
+jest.mock('~/server/services/Config', () => ({
+ getCachedTools: jest.fn().mockResolvedValue({}),
+}));
+
+jest.mock('~/models/Role', () => ({
+ getRoleByName: jest.fn(),
+}));
+
+jest.mock('~/server/middleware', () => ({
+ requireJwtAuth: (req, res, next) => next(),
+ canAccessPromptViaGroup: jest.requireActual('~/server/middleware').canAccessPromptViaGroup,
+ canAccessPromptGroupResource:
+ jest.requireActual('~/server/middleware').canAccessPromptGroupResource,
+}));
+
+let app;
+let mongoServer;
+let promptRoutes;
+let Prompt, PromptGroup, AclEntry, AccessRole, User;
+let testUsers, testRoles;
+let grantPermission;
+let currentTestUser; // Track current user for middleware
+
+// Helper function to set user in middleware
+function setTestUser(app, user) {
+ currentTestUser = user;
+}
+
+beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+ await mongoose.connect(mongoUri);
+
+ // Initialize models
+ const dbModels = require('~/db/models');
+ Prompt = dbModels.Prompt;
+ PromptGroup = dbModels.PromptGroup;
+ AclEntry = dbModels.AclEntry;
+ AccessRole = dbModels.AccessRole;
+ User = dbModels.User;
+
+ // Import permission service
+ const permissionService = require('~/server/services/PermissionService');
+ grantPermission = permissionService.grantPermission;
+
+ // Create test data
+ await setupTestData();
+
+ // Setup Express app
+ app = express();
+ app.use(express.json());
+
+ // Add user middleware before routes
+ app.use((req, res, next) => {
+ if (currentTestUser) {
+ req.user = {
+ ...(currentTestUser.toObject ? currentTestUser.toObject() : currentTestUser),
+ id: currentTestUser._id.toString(),
+ _id: currentTestUser._id,
+ name: currentTestUser.name,
+ role: currentTestUser.role,
+ };
+ }
+ next();
+ });
+
+ // Set default user
+ currentTestUser = testUsers.owner;
+
+ // Import routes after middleware is set up
+ promptRoutes = require('./prompts');
+ app.use('/api/prompts', promptRoutes);
+});
+
+afterEach(() => {
+ // Always reset to owner user after each test for isolation
+ if (currentTestUser !== testUsers.owner) {
+ currentTestUser = testUsers.owner;
+ }
+});
+
+afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ jest.clearAllMocks();
+});
+
+async function setupTestData() {
+ // Create access roles for promptGroups
+ testRoles = {
+ viewer: await AccessRole.create({
+ accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
+ name: 'Viewer',
+ resourceType: ResourceType.PROMPTGROUP,
+ permBits: PermissionBits.VIEW,
+ }),
+ editor: await AccessRole.create({
+ accessRoleId: AccessRoleIds.PROMPTGROUP_EDITOR,
+ name: 'Editor',
+ resourceType: ResourceType.PROMPTGROUP,
+ permBits: PermissionBits.VIEW | PermissionBits.EDIT,
+ }),
+ owner: await AccessRole.create({
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ name: 'Owner',
+ resourceType: ResourceType.PROMPTGROUP,
+ permBits:
+ PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
+ }),
+ };
+
+ // Create test users
+ testUsers = {
+ owner: await User.create({
+ name: 'Prompt Owner',
+ email: 'owner@example.com',
+ role: SystemRoles.USER,
+ }),
+ viewer: await User.create({
+ name: 'Prompt Viewer',
+ email: 'viewer@example.com',
+ role: SystemRoles.USER,
+ }),
+ editor: await User.create({
+ name: 'Prompt Editor',
+ email: 'editor@example.com',
+ role: SystemRoles.USER,
+ }),
+ noAccess: await User.create({
+ name: 'No Access',
+ email: 'noaccess@example.com',
+ role: SystemRoles.USER,
+ }),
+ admin: await User.create({
+ name: 'Admin',
+ email: 'admin@example.com',
+ role: SystemRoles.ADMIN,
+ }),
+ };
+
+ // Mock getRoleByName
+ const { getRoleByName } = require('~/models/Role');
+ getRoleByName.mockImplementation((roleName) => {
+ switch (roleName) {
+ case SystemRoles.USER:
+ return { permissions: { PROMPTS: { USE: true, CREATE: true } } };
+ case SystemRoles.ADMIN:
+ return { permissions: { PROMPTS: { USE: true, CREATE: true, SHARED_GLOBAL: true } } };
+ default:
+ return null;
+ }
+ });
+}
+
+describe('Prompt Routes - ACL Permissions', () => {
+ let consoleErrorSpy;
+
+ beforeEach(() => {
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
+ });
+
+ afterEach(() => {
+ consoleErrorSpy.mockRestore();
+ });
+
+ // Simple test to verify route is loaded
+ it('should have routes loaded', async () => {
+ // This should at least not crash
+ const response = await request(app).get('/api/prompts/test-404');
+
+ // We expect a 401 or 404, not 500
+ expect(response.status).not.toBe(500);
+ });
+
+ describe('POST /api/prompts - Create Prompt', () => {
+ afterEach(async () => {
+ await Prompt.deleteMany({});
+ await PromptGroup.deleteMany({});
+ await AclEntry.deleteMany({});
+ });
+
+ it('should create a prompt and grant owner permissions', async () => {
+ const promptData = {
+ prompt: {
+ prompt: 'Test prompt content',
+ type: 'text',
+ },
+ group: {
+ name: 'Test Prompt Group',
+ },
+ };
+
+ const response = await request(app).post('/api/prompts').send(promptData);
+
+ expect(response.status).toBe(200);
+ expect(response.body.prompt).toBeDefined();
+ expect(response.body.prompt.prompt).toBe(promptData.prompt.prompt);
+
+ // Check ACL entry was created
+ const aclEntry = await AclEntry.findOne({
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: response.body.prompt.groupId,
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ });
+
+ expect(aclEntry).toBeTruthy();
+ expect(aclEntry.roleId.toString()).toBe(testRoles.owner._id.toString());
+ });
+
+ it('should create a prompt group with prompt and grant owner permissions', async () => {
+ const promptData = {
+ prompt: {
+ prompt: 'Group prompt content',
+ // Remove 'name' from prompt - it's not in the schema
+ },
+ group: {
+ name: 'Test Group',
+ category: 'testing',
+ },
+ };
+
+ const response = await request(app).post('/api/prompts').send(promptData).expect(200);
+
+ expect(response.body.prompt).toBeDefined();
+ expect(response.body.group).toBeDefined();
+ expect(response.body.group.name).toBe(promptData.group.name);
+
+ // Check ACL entry was created for the promptGroup
+ const aclEntry = await AclEntry.findOne({
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: response.body.group._id,
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ });
+
+ expect(aclEntry).toBeTruthy();
+ });
+ });
+
+ describe('GET /api/prompts/:promptId - Get Prompt', () => {
+ let testPrompt;
+ let testGroup;
+
+ beforeEach(async () => {
+ // Create a prompt group first
+ testGroup = await PromptGroup.create({
+ name: 'Test Group',
+ category: 'testing',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ });
+
+ // Create a prompt
+ testPrompt = await Prompt.create({
+ prompt: 'Test prompt for retrieval',
+ name: 'Get Test',
+ author: testUsers.owner._id,
+ type: 'text',
+ groupId: testGroup._id,
+ });
+ });
+
+ afterEach(async () => {
+ await Prompt.deleteMany({});
+ await PromptGroup.deleteMany({});
+ await AclEntry.deleteMany({});
+ });
+
+ it('should retrieve prompt when user has view permissions', async () => {
+ // Grant view permissions on the promptGroup
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testGroup._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
+ grantedBy: testUsers.owner._id,
+ });
+
+ const response = await request(app).get(`/api/prompts/${testPrompt._id}`);
+ expect(response.status).toBe(200);
+ expect(response.body._id).toBe(testPrompt._id.toString());
+ expect(response.body.prompt).toBe(testPrompt.prompt);
+ });
+
+ it('should deny access when user has no permissions', async () => {
+ // Change the user to one without access
+ setTestUser(app, testUsers.noAccess);
+
+ const response = await request(app).get(`/api/prompts/${testPrompt._id}`).expect(403);
+
+ // Verify error response
+ expect(response.body.error).toBe('Forbidden');
+ expect(response.body.message).toBe('Insufficient permissions to access this promptGroup');
+ });
+
+ it('should allow admin access without explicit permissions', async () => {
+ // Set admin user
+ setTestUser(app, testUsers.admin);
+
+ const response = await request(app).get(`/api/prompts/${testPrompt._id}`).expect(200);
+
+ expect(response.body._id).toBe(testPrompt._id.toString());
+ });
+ });
+
+ describe('DELETE /api/prompts/:promptId - Delete Prompt', () => {
+ let testPrompt;
+ let testGroup;
+
+ beforeEach(async () => {
+ // Create group with prompt
+ testGroup = await PromptGroup.create({
+ name: 'Delete Test Group',
+ category: 'testing',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ });
+
+ testPrompt = await Prompt.create({
+ prompt: 'Test prompt for deletion',
+ name: 'Delete Test',
+ author: testUsers.owner._id,
+ type: 'text',
+ groupId: testGroup._id,
+ });
+
+ // Add prompt to group
+ testGroup.productionId = testPrompt._id;
+ testGroup.promptIds = [testPrompt._id];
+ await testGroup.save();
+
+ // Grant owner permissions on the promptGroup
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testGroup._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+ });
+
+ afterEach(async () => {
+ await Prompt.deleteMany({});
+ await PromptGroup.deleteMany({});
+ await AclEntry.deleteMany({});
+ });
+
+ it('should delete prompt when user has delete permissions', async () => {
+ const response = await request(app)
+ .delete(`/api/prompts/${testPrompt._id}`)
+ .query({ groupId: testGroup._id.toString() })
+ .expect(200);
+
+ expect(response.body.prompt).toBe('Prompt deleted successfully');
+
+ // Verify prompt was deleted
+ const deletedPrompt = await Prompt.findById(testPrompt._id);
+ expect(deletedPrompt).toBeNull();
+
+ // Verify ACL entries were removed
+ const aclEntries = await AclEntry.find({
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testGroup._id,
+ });
+ expect(aclEntries).toHaveLength(0);
+ });
+
+ it('should deny deletion when user lacks delete permissions', async () => {
+ // Create a prompt as a different user (not the one trying to delete)
+ const authorPrompt = await Prompt.create({
+ prompt: 'Test prompt by another user',
+ name: 'Another User Prompt',
+ author: testUsers.editor._id, // Different author
+ type: 'text',
+ groupId: testGroup._id,
+ });
+
+ // Grant only viewer permissions to viewer user on the promptGroup
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.viewer._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testGroup._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
+ grantedBy: testUsers.editor._id,
+ });
+
+ // Set viewer user
+ setTestUser(app, testUsers.viewer);
+
+ await request(app)
+ .delete(`/api/prompts/${authorPrompt._id}`)
+ .query({ groupId: testGroup._id.toString() })
+ .expect(403);
+
+ // Verify prompt still exists
+ const prompt = await Prompt.findById(authorPrompt._id);
+ expect(prompt).toBeTruthy();
+ });
+ });
+
+ describe('PATCH /api/prompts/:promptId/tags/production - Make Production', () => {
+ let testPrompt;
+ let testGroup;
+
+ beforeEach(async () => {
+ // Create group
+ testGroup = await PromptGroup.create({
+ name: 'Production Test Group',
+ category: 'testing',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ });
+
+ testPrompt = await Prompt.create({
+ prompt: 'Test prompt for production',
+ name: 'Production Test',
+ author: testUsers.owner._id,
+ type: 'text',
+ groupId: testGroup._id,
+ });
+ });
+
+ afterEach(async () => {
+ await Prompt.deleteMany({});
+ await PromptGroup.deleteMany({});
+ await AclEntry.deleteMany({});
+ });
+
+ it('should make prompt production when user has edit permissions', async () => {
+ // Grant edit permissions on the promptGroup
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testGroup._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_EDITOR,
+ grantedBy: testUsers.owner._id,
+ });
+
+ // Ensure owner user
+ setTestUser(app, testUsers.owner);
+
+ const response = await request(app)
+ .patch(`/api/prompts/${testPrompt._id}/tags/production`)
+ .expect(200);
+
+ expect(response.body.message).toBe('Prompt production made successfully');
+
+ // Verify the group was updated
+ const updatedGroup = await PromptGroup.findById(testGroup._id);
+ expect(updatedGroup.productionId.toString()).toBe(testPrompt._id.toString());
+ });
+
+ it('should deny making production when user lacks edit permissions', async () => {
+ // Grant only view permissions to viewer on the promptGroup
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.viewer._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testGroup._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
+ grantedBy: testUsers.owner._id,
+ });
+
+ // Set viewer user
+ setTestUser(app, testUsers.viewer);
+
+ await request(app).patch(`/api/prompts/${testPrompt._id}/tags/production`).expect(403);
+
+ // Verify prompt hasn't changed
+ const unchangedGroup = await PromptGroup.findById(testGroup._id);
+ expect(unchangedGroup.productionId.toString()).not.toBe(testPrompt._id.toString());
+ });
+ });
+
+ describe('Public Access', () => {
+ let publicPrompt;
+ let publicGroup;
+
+ beforeEach(async () => {
+ // Create a prompt group
+ publicGroup = await PromptGroup.create({
+ name: 'Public Test Group',
+ category: 'testing',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ });
+
+ // Create a public prompt
+ publicPrompt = await Prompt.create({
+ prompt: 'Public prompt content',
+ name: 'Public Test',
+ author: testUsers.owner._id,
+ type: 'text',
+ groupId: publicGroup._id,
+ });
+
+ // Grant public viewer access on the promptGroup
+ await grantPermission({
+ principalType: PrincipalType.PUBLIC,
+ principalId: null,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: publicGroup._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
+ grantedBy: testUsers.owner._id,
+ });
+ });
+
+ afterEach(async () => {
+ await Prompt.deleteMany({});
+ await PromptGroup.deleteMany({});
+ await AclEntry.deleteMany({});
+ });
+
+ it('should allow any user to view public prompts', async () => {
+ // Change user to someone without explicit permissions
+ setTestUser(app, testUsers.noAccess);
+
+ const response = await request(app).get(`/api/prompts/${publicPrompt._id}`).expect(200);
+
+ expect(response.body._id).toBe(publicPrompt._id.toString());
+ });
+ });
+
+ describe('PATCH /api/prompts/groups/:groupId - Update Prompt Group Security', () => {
+ let testGroup;
+
+ beforeEach(async () => {
+ // Create a prompt group
+ testGroup = await PromptGroup.create({
+ name: 'Security Test Group',
+ category: 'security-test',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ });
+
+ // Grant owner permissions
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: testGroup._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+ });
+
+ afterEach(async () => {
+ await PromptGroup.deleteMany({});
+ await AclEntry.deleteMany({});
+ });
+
+ it('should allow updating allowed fields (name, category, oneliner)', async () => {
+ const updateData = {
+ name: 'Updated Group Name',
+ category: 'updated-category',
+ oneliner: 'Updated description',
+ };
+
+ const response = await request(app)
+ .patch(`/api/prompts/groups/${testGroup._id}`)
+ .send(updateData)
+ .expect(200);
+
+ expect(response.body.name).toBe(updateData.name);
+ expect(response.body.category).toBe(updateData.category);
+ expect(response.body.oneliner).toBe(updateData.oneliner);
+ });
+
+ it('should reject request with author field (400 Bad Request)', async () => {
+ const maliciousUpdate = {
+ name: 'Legit Update',
+ author: testUsers.noAccess._id.toString(), // Try to change ownership
+ };
+
+ const response = await request(app)
+ .patch(`/api/prompts/groups/${testGroup._id}`)
+ .send(maliciousUpdate)
+ .expect(400);
+
+ // Verify the request was rejected
+ expect(response.body.error).toBe('Invalid request body');
+ expect(response.body.details).toBeDefined();
+ });
+
+ it('should reject request with authorName field (400 Bad Request)', async () => {
+ const maliciousUpdate = {
+ name: 'Legit Update',
+ authorName: 'Malicious Author Name',
+ };
+
+ const response = await request(app)
+ .patch(`/api/prompts/groups/${testGroup._id}`)
+ .send(maliciousUpdate)
+ .expect(400);
+
+ // Verify the request was rejected
+ expect(response.body.error).toBe('Invalid request body');
+ });
+
+ it('should reject request with _id field (400 Bad Request)', async () => {
+ const newId = new ObjectId();
+ const maliciousUpdate = {
+ name: 'Legit Update',
+ _id: newId.toString(),
+ };
+
+ const response = await request(app)
+ .patch(`/api/prompts/groups/${testGroup._id}`)
+ .send(maliciousUpdate)
+ .expect(400);
+
+ // Verify the request was rejected
+ expect(response.body.error).toBe('Invalid request body');
+ });
+
+ it('should reject request with productionId field (400 Bad Request)', async () => {
+ const newProductionId = new ObjectId();
+ const maliciousUpdate = {
+ name: 'Legit Update',
+ productionId: newProductionId.toString(),
+ };
+
+ const response = await request(app)
+ .patch(`/api/prompts/groups/${testGroup._id}`)
+ .send(maliciousUpdate)
+ .expect(400);
+
+ // Verify the request was rejected
+ expect(response.body.error).toBe('Invalid request body');
+ });
+
+ it('should reject request with createdAt field (400 Bad Request)', async () => {
+ const maliciousDate = new Date('2020-01-01');
+ const maliciousUpdate = {
+ name: 'Legit Update',
+ createdAt: maliciousDate.toISOString(),
+ };
+
+ const response = await request(app)
+ .patch(`/api/prompts/groups/${testGroup._id}`)
+ .send(maliciousUpdate)
+ .expect(400);
+
+ // Verify the request was rejected
+ expect(response.body.error).toBe('Invalid request body');
+ });
+
+ it('should reject request with __v field (400 Bad Request)', async () => {
+ const maliciousUpdate = {
+ name: 'Legit Update',
+ __v: 999,
+ };
+
+ const response = await request(app)
+ .patch(`/api/prompts/groups/${testGroup._id}`)
+ .send(maliciousUpdate)
+ .expect(400);
+
+ // Verify the request was rejected
+ expect(response.body.error).toBe('Invalid request body');
+ });
+
+ it('should reject request with multiple sensitive fields (400 Bad Request)', async () => {
+ const maliciousUpdate = {
+ name: 'Legit Update',
+ author: testUsers.noAccess._id.toString(),
+ authorName: 'Hacker',
+ _id: new ObjectId().toString(),
+ productionId: new ObjectId().toString(),
+ createdAt: new Date('2020-01-01').toISOString(),
+ __v: 999,
+ };
+
+ const response = await request(app)
+ .patch(`/api/prompts/groups/${testGroup._id}`)
+ .send(maliciousUpdate)
+ .expect(400);
+
+ // Verify the request was rejected with validation errors
+ expect(response.body.error).toBe('Invalid request body');
+ expect(response.body.details).toBeDefined();
+ expect(Array.isArray(response.body.details)).toBe(true);
+ });
+ });
+
+ describe('Pagination', () => {
+ beforeEach(async () => {
+ // Create multiple prompt groups for pagination testing
+ const groups = [];
+ for (let i = 0; i < 15; i++) {
+ const group = await PromptGroup.create({
+ name: `Test Group ${i + 1}`,
+ category: 'pagination-test',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ updatedAt: new Date(Date.now() - i * 1000), // Stagger updatedAt for consistent ordering
+ });
+ groups.push(group);
+
+ // Grant owner permissions on each group
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: group._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+ }
+ });
+
+ afterEach(async () => {
+ await PromptGroup.deleteMany({});
+ await AclEntry.deleteMany({});
+ });
+
+ it('should correctly indicate hasMore when there are more pages', async () => {
+ const response = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '10' })
+ .expect(200);
+
+ expect(response.body.promptGroups).toHaveLength(10);
+ expect(response.body.has_more).toBe(true);
+ expect(response.body.after).toBeTruthy();
+ // Since has_more is true, pages should be a high number (9999 in our fix)
+ expect(parseInt(response.body.pages)).toBeGreaterThan(1);
+ });
+
+ it('should correctly indicate no more pages on the last page', async () => {
+ // First get the cursor for page 2
+ const firstPage = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '10' })
+ .expect(200);
+
+ expect(firstPage.body.has_more).toBe(true);
+ expect(firstPage.body.after).toBeTruthy();
+
+ // Now fetch the second page using the cursor
+ const response = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '10', cursor: firstPage.body.after })
+ .expect(200);
+
+ expect(response.body.promptGroups).toHaveLength(5); // 15 total, 10 on page 1, 5 on page 2
+ expect(response.body.has_more).toBe(false);
+ });
+
+ it('should support cursor-based pagination', async () => {
+ // First page
+ const firstPage = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '5' })
+ .expect(200);
+
+ expect(firstPage.body.promptGroups).toHaveLength(5);
+ expect(firstPage.body.has_more).toBe(true);
+ expect(firstPage.body.after).toBeTruthy();
+
+ // Second page using cursor
+ const secondPage = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '5', cursor: firstPage.body.after })
+ .expect(200);
+
+ expect(secondPage.body.promptGroups).toHaveLength(5);
+ expect(secondPage.body.has_more).toBe(true);
+ expect(secondPage.body.after).toBeTruthy();
+
+ // Verify different groups
+ const firstPageIds = firstPage.body.promptGroups.map((g) => g._id);
+ const secondPageIds = secondPage.body.promptGroups.map((g) => g._id);
+ expect(firstPageIds).not.toEqual(secondPageIds);
+ });
+
+ it('should paginate correctly with category filtering', async () => {
+ // Create groups with different categories
+ await PromptGroup.deleteMany({}); // Clear existing groups
+ await AclEntry.deleteMany({});
+
+ // Create 8 groups with category 'test-cat-1'
+ for (let i = 0; i < 8; i++) {
+ const group = await PromptGroup.create({
+ name: `Category 1 Group ${i + 1}`,
+ category: 'test-cat-1',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ updatedAt: new Date(Date.now() - i * 1000),
+ });
+
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: group._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+ }
+
+ // Create 7 groups with category 'test-cat-2'
+ for (let i = 0; i < 7; i++) {
+ const group = await PromptGroup.create({
+ name: `Category 2 Group ${i + 1}`,
+ category: 'test-cat-2',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ updatedAt: new Date(Date.now() - (i + 8) * 1000),
+ });
+
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: group._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+ }
+
+ // Test pagination with category filter
+ const firstPage = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '5', category: 'test-cat-1' })
+ .expect(200);
+
+ expect(firstPage.body.promptGroups).toHaveLength(5);
+ expect(firstPage.body.promptGroups.every((g) => g.category === 'test-cat-1')).toBe(true);
+ expect(firstPage.body.has_more).toBe(true);
+ expect(firstPage.body.after).toBeTruthy();
+
+ const secondPage = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '5', cursor: firstPage.body.after, category: 'test-cat-1' })
+ .expect(200);
+
+ expect(secondPage.body.promptGroups).toHaveLength(3); // 8 total, 5 on page 1, 3 on page 2
+ expect(secondPage.body.promptGroups.every((g) => g.category === 'test-cat-1')).toBe(true);
+ expect(secondPage.body.has_more).toBe(false);
+ });
+
+ it('should paginate correctly with name/keyword filtering', async () => {
+ // Create groups with specific names
+ await PromptGroup.deleteMany({}); // Clear existing groups
+ await AclEntry.deleteMany({});
+
+ // Create 12 groups with 'Search' in the name
+ for (let i = 0; i < 12; i++) {
+ const group = await PromptGroup.create({
+ name: `Search Test Group ${i + 1}`,
+ category: 'search-test',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ updatedAt: new Date(Date.now() - i * 1000),
+ });
+
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: group._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+ }
+
+ // Create 5 groups without 'Search' in the name
+ for (let i = 0; i < 5; i++) {
+ const group = await PromptGroup.create({
+ name: `Other Group ${i + 1}`,
+ category: 'other-test',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ updatedAt: new Date(Date.now() - (i + 12) * 1000),
+ });
+
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: group._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+ }
+
+ // Test pagination with name filter
+ const firstPage = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '10', name: 'Search' })
+ .expect(200);
+
+ expect(firstPage.body.promptGroups).toHaveLength(10);
+ expect(firstPage.body.promptGroups.every((g) => g.name.includes('Search'))).toBe(true);
+ expect(firstPage.body.has_more).toBe(true);
+ expect(firstPage.body.after).toBeTruthy();
+
+ const secondPage = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '10', cursor: firstPage.body.after, name: 'Search' })
+ .expect(200);
+
+ expect(secondPage.body.promptGroups).toHaveLength(2); // 12 total, 10 on page 1, 2 on page 2
+ expect(secondPage.body.promptGroups.every((g) => g.name.includes('Search'))).toBe(true);
+ expect(secondPage.body.has_more).toBe(false);
+ });
+
+ it('should paginate correctly with combined filters', async () => {
+ // Create groups with various combinations
+ await PromptGroup.deleteMany({}); // Clear existing groups
+ await AclEntry.deleteMany({});
+
+ // Create 6 groups matching both category and name filters
+ for (let i = 0; i < 6; i++) {
+ const group = await PromptGroup.create({
+ name: `API Test Group ${i + 1}`,
+ category: 'api-category',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ updatedAt: new Date(Date.now() - i * 1000),
+ });
+
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: group._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+ }
+
+ // Create groups that only match one filter
+ for (let i = 0; i < 4; i++) {
+ const group = await PromptGroup.create({
+ name: `API Other Group ${i + 1}`,
+ category: 'other-category',
+ author: testUsers.owner._id,
+ authorName: testUsers.owner.name,
+ productionId: new ObjectId(),
+ updatedAt: new Date(Date.now() - (i + 6) * 1000),
+ });
+
+ await grantPermission({
+ principalType: PrincipalType.USER,
+ principalId: testUsers.owner._id,
+ resourceType: ResourceType.PROMPTGROUP,
+ resourceId: group._id,
+ accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
+ grantedBy: testUsers.owner._id,
+ });
+ }
+
+ // Test pagination with both filters
+ const response = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '5', name: 'API', category: 'api-category' })
+ .expect(200);
+
+ expect(response.body.promptGroups).toHaveLength(5);
+ expect(
+ response.body.promptGroups.every(
+ (g) => g.name.includes('API') && g.category === 'api-category',
+ ),
+ ).toBe(true);
+ expect(response.body.has_more).toBe(true);
+ expect(response.body.after).toBeTruthy();
+
+ // Page 2
+ const page2 = await request(app)
+ .get('/api/prompts/groups')
+ .query({ limit: '5', cursor: response.body.after, name: 'API', category: 'api-category' })
+ .expect(200);
+
+ expect(page2.body.promptGroups).toHaveLength(1); // 6 total, 5 on page 1, 1 on page 2
+ expect(page2.body.has_more).toBe(false);
+ });
+ });
+});
diff --git a/api/server/routes/roles.js b/api/server/routes/roles.js
new file mode 100644
index 0000000000..abb53141bd
--- /dev/null
+++ b/api/server/routes/roles.js
@@ -0,0 +1,163 @@
+const express = require('express');
+const {
+ SystemRoles,
+ roleDefaults,
+ PermissionTypes,
+ agentPermissionsSchema,
+ promptPermissionsSchema,
+ memoryPermissionsSchema,
+ marketplacePermissionsSchema,
+ peoplePickerPermissionsSchema,
+ mcpServersPermissionsSchema,
+} = require('librechat-data-provider');
+const { checkAdmin, requireJwtAuth } = require('~/server/middleware');
+const { updateRoleByName, getRoleByName } = require('~/models/Role');
+
+const router = express.Router();
+router.use(requireJwtAuth);
+
+/**
+ * Permission configuration mapping
+ * Maps route paths to their corresponding schemas and permission types
+ */
+const permissionConfigs = {
+ prompts: {
+ schema: promptPermissionsSchema,
+ permissionType: PermissionTypes.PROMPTS,
+ errorMessage: 'Invalid prompt permissions.',
+ },
+ agents: {
+ schema: agentPermissionsSchema,
+ permissionType: PermissionTypes.AGENTS,
+ errorMessage: 'Invalid agent permissions.',
+ },
+ memories: {
+ schema: memoryPermissionsSchema,
+ permissionType: PermissionTypes.MEMORIES,
+ errorMessage: 'Invalid memory permissions.',
+ },
+ 'people-picker': {
+ schema: peoplePickerPermissionsSchema,
+ permissionType: PermissionTypes.PEOPLE_PICKER,
+ errorMessage: 'Invalid people picker permissions.',
+ },
+ 'mcp-servers': {
+ schema: mcpServersPermissionsSchema,
+ permissionType: PermissionTypes.MCP_SERVERS,
+ errorMessage: 'Invalid MCP servers permissions.',
+ },
+ marketplace: {
+ schema: marketplacePermissionsSchema,
+ permissionType: PermissionTypes.MARKETPLACE,
+ errorMessage: 'Invalid marketplace permissions.',
+ },
+};
+
+/**
+ * Generic handler for updating permissions
+ * @param {string} permissionKey - The key from permissionConfigs
+ * @returns {Function} Express route handler
+ */
+const createPermissionUpdateHandler = (permissionKey) => {
+ const config = permissionConfigs[permissionKey];
+
+ return async (req, res) => {
+ const { roleName: _r } = req.params;
+ // TODO: TEMP, use a better parsing for roleName
+ const roleName = _r.toUpperCase();
+ const updates = req.body;
+
+ try {
+ const parsedUpdates = config.schema.partial().parse(updates);
+
+ const role = await getRoleByName(roleName);
+ if (!role) {
+ return res.status(404).send({ message: 'Role not found' });
+ }
+
+ const currentPermissions =
+ role.permissions?.[config.permissionType] || role[config.permissionType] || {};
+
+ const mergedUpdates = {
+ permissions: {
+ ...role.permissions,
+ [config.permissionType]: {
+ ...currentPermissions,
+ ...parsedUpdates,
+ },
+ },
+ };
+
+ const updatedRole = await updateRoleByName(roleName, mergedUpdates);
+ res.status(200).send(updatedRole);
+ } catch (error) {
+ return res.status(400).send({ message: config.errorMessage, error: error.errors });
+ }
+ };
+};
+
+/**
+ * GET /api/roles/:roleName
+ * Get a specific role by name
+ */
+router.get('/:roleName', async (req, res) => {
+ const { roleName: _r } = req.params;
+ // TODO: TEMP, use a better parsing for roleName
+ const roleName = _r.toUpperCase();
+
+ if (
+ (req.user.role !== SystemRoles.ADMIN && roleName === SystemRoles.ADMIN) ||
+ (req.user.role !== SystemRoles.ADMIN && !roleDefaults[roleName])
+ ) {
+ return res.status(403).send({ message: 'Unauthorized' });
+ }
+
+ try {
+ const role = await getRoleByName(roleName, '-_id -__v');
+ if (!role) {
+ return res.status(404).send({ message: 'Role not found' });
+ }
+
+ res.status(200).send(role);
+ } catch (error) {
+ return res.status(500).send({ message: 'Failed to retrieve role', error: error.message });
+ }
+});
+
+/**
+ * PUT /api/roles/:roleName/prompts
+ * Update prompt permissions for a specific role
+ */
+router.put('/:roleName/prompts', checkAdmin, createPermissionUpdateHandler('prompts'));
+
+/**
+ * PUT /api/roles/:roleName/agents
+ * Update agent permissions for a specific role
+ */
+router.put('/:roleName/agents', checkAdmin, createPermissionUpdateHandler('agents'));
+
+/**
+ * PUT /api/roles/:roleName/memories
+ * Update memory permissions for a specific role
+ */
+router.put('/:roleName/memories', checkAdmin, createPermissionUpdateHandler('memories'));
+
+/**
+ * PUT /api/roles/:roleName/people-picker
+ * Update people picker permissions for a specific role
+ */
+router.put('/:roleName/people-picker', checkAdmin, createPermissionUpdateHandler('people-picker'));
+
+/**
+ * PUT /api/roles/:roleName/mcp-servers
+ * Update MCP servers permissions for a specific role
+ */
+router.put('/:roleName/mcp-servers', checkAdmin, createPermissionUpdateHandler('mcp-servers'));
+
+/**
+ * PUT /api/roles/:roleName/marketplace
+ * Update marketplace permissions for a specific role
+ */
+router.put('/:roleName/marketplace', checkAdmin, createPermissionUpdateHandler('marketplace'));
+
+module.exports = router;
diff --git a/api/server/routes/search.js b/api/server/routes/search.js
new file mode 100644
index 0000000000..2cd2fc3534
--- /dev/null
+++ b/api/server/routes/search.js
@@ -0,0 +1,28 @@
+const express = require('express');
+const { MeiliSearch } = require('meilisearch');
+const { isEnabled } = require('@librechat/api');
+const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
+
+const router = express.Router();
+
+router.use(requireJwtAuth);
+
+router.get('/enable', async function (req, res) {
+ if (!isEnabled(process.env.SEARCH)) {
+ return res.send(false);
+ }
+
+ try {
+ const client = new MeiliSearch({
+ host: process.env.MEILI_HOST,
+ apiKey: process.env.MEILI_MASTER_KEY,
+ });
+
+ const { status } = await client.health();
+ return res.send(status === 'available');
+ } catch (error) {
+ return res.send(false);
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/settings.js b/api/server/routes/settings.js
new file mode 100644
index 0000000000..22162fed4e
--- /dev/null
+++ b/api/server/routes/settings.js
@@ -0,0 +1,13 @@
+const express = require('express');
+const {
+ updateFavoritesController,
+ getFavoritesController,
+} = require('~/server/controllers/FavoritesController');
+const { requireJwtAuth } = require('~/server/middleware');
+
+const router = express.Router();
+
+router.get('/favorites', requireJwtAuth, getFavoritesController);
+router.post('/favorites', requireJwtAuth, updateFavoritesController);
+
+module.exports = router;
diff --git a/api/server/routes/share.js b/api/server/routes/share.js
new file mode 100644
index 0000000000..6400b8b637
--- /dev/null
+++ b/api/server/routes/share.js
@@ -0,0 +1,144 @@
+const express = require('express');
+const { isEnabled } = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+const {
+ getSharedMessages,
+ createSharedLink,
+ updateSharedLink,
+ deleteSharedLink,
+ getSharedLinks,
+ getSharedLink,
+} = require('~/models');
+const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
+const router = express.Router();
+
+/**
+ * Shared messages
+ */
+const allowSharedLinks =
+ process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
+
+if (allowSharedLinks) {
+ const allowSharedLinksPublic =
+ process.env.ALLOW_SHARED_LINKS_PUBLIC === undefined ||
+ isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC);
+ router.get(
+ '/:shareId',
+ allowSharedLinksPublic ? (req, res, next) => next() : requireJwtAuth,
+ async (req, res) => {
+ try {
+ const share = await getSharedMessages(req.params.shareId);
+
+ if (share) {
+ res.status(200).json(share);
+ } else {
+ res.status(404).end();
+ }
+ } catch (error) {
+ logger.error('Error getting shared messages:', error);
+ res.status(500).json({ message: 'Error getting shared messages' });
+ }
+ },
+ );
+}
+
+/**
+ * Shared links
+ */
+router.get('/', requireJwtAuth, async (req, res) => {
+ try {
+ const params = {
+ pageParam: req.query.cursor,
+ pageSize: Math.max(1, parseInt(req.query.pageSize) || 10),
+ isPublic: isEnabled(req.query.isPublic),
+ sortBy: ['createdAt', 'title'].includes(req.query.sortBy) ? req.query.sortBy : 'createdAt',
+ sortDirection: ['asc', 'desc'].includes(req.query.sortDirection)
+ ? req.query.sortDirection
+ : 'desc',
+ search: req.query.search ? decodeURIComponent(req.query.search.trim()) : undefined,
+ };
+
+ const result = await getSharedLinks(
+ req.user.id,
+ params.pageParam,
+ params.pageSize,
+ params.isPublic,
+ params.sortBy,
+ params.sortDirection,
+ params.search,
+ );
+
+ res.status(200).send({
+ links: result.links,
+ nextCursor: result.nextCursor,
+ hasNextPage: result.hasNextPage,
+ });
+ } catch (error) {
+ logger.error('Error getting shared links:', error);
+ res.status(500).json({
+ message: 'Error getting shared links',
+ error: error.message,
+ });
+ }
+});
+
+router.get('/link/:conversationId', requireJwtAuth, async (req, res) => {
+ try {
+ const share = await getSharedLink(req.user.id, req.params.conversationId);
+
+ return res.status(200).json({
+ success: share.success,
+ shareId: share.shareId,
+ conversationId: req.params.conversationId,
+ });
+ } catch (error) {
+ logger.error('Error getting shared link:', error);
+ res.status(500).json({ message: 'Error getting shared link' });
+ }
+});
+
+router.post('/:conversationId', requireJwtAuth, async (req, res) => {
+ try {
+ const { targetMessageId } = req.body;
+ const created = await createSharedLink(req.user.id, req.params.conversationId, targetMessageId);
+ if (created) {
+ res.status(200).json(created);
+ } else {
+ res.status(404).end();
+ }
+ } catch (error) {
+ logger.error('Error creating shared link:', error);
+ res.status(500).json({ message: 'Error creating shared link' });
+ }
+});
+
+router.patch('/:shareId', requireJwtAuth, async (req, res) => {
+ try {
+ const updatedShare = await updateSharedLink(req.user.id, req.params.shareId);
+ if (updatedShare) {
+ res.status(200).json(updatedShare);
+ } else {
+ res.status(404).end();
+ }
+ } catch (error) {
+ logger.error('Error updating shared link:', error);
+ res.status(500).json({ message: 'Error updating shared link' });
+ }
+});
+
+router.delete('/:shareId', requireJwtAuth, async (req, res) => {
+ try {
+ const result = await deleteSharedLink(req.user.id, req.params.shareId);
+
+ if (!result) {
+ return res.status(404).json({ message: 'Share not found' });
+ }
+
+ return res.status(200).json(result);
+ } catch (error) {
+ logger.error('Error deleting shared link:', error);
+ return res.status(400).json({ message: 'Error deleting shared link' });
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/static.js b/api/server/routes/static.js
new file mode 100644
index 0000000000..12dfc0ed49
--- /dev/null
+++ b/api/server/routes/static.js
@@ -0,0 +1,11 @@
+const express = require('express');
+const { isEnabled } = require('@librechat/api');
+const staticCache = require('../utils/staticCache');
+const paths = require('~/config/paths');
+
+const skipGzipScan = !isEnabled(process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN);
+
+const router = express.Router();
+router.use(staticCache(paths.imageOutput, { skipGzipScan }));
+
+module.exports = router;
diff --git a/api/server/routes/tags.js b/api/server/routes/tags.js
new file mode 100644
index 0000000000..0a4ee5084c
--- /dev/null
+++ b/api/server/routes/tags.js
@@ -0,0 +1,124 @@
+const express = require('express');
+const { logger } = require('@librechat/data-schemas');
+const { generateCheckAccess } = require('@librechat/api');
+const { PermissionTypes, Permissions } = require('librechat-data-provider');
+const {
+ updateTagsForConversation,
+ updateConversationTag,
+ createConversationTag,
+ deleteConversationTag,
+ getConversationTags,
+} = require('~/models/ConversationTag');
+const { requireJwtAuth } = require('~/server/middleware');
+const { getRoleByName } = require('~/models/Role');
+
+const router = express.Router();
+
+const checkBookmarkAccess = generateCheckAccess({
+ permissionType: PermissionTypes.BOOKMARKS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+});
+
+router.use(requireJwtAuth);
+router.use(checkBookmarkAccess);
+
+/**
+ * GET /
+ * Retrieves all conversation tags for the authenticated user.
+ * @param {Object} req - Express request object
+ * @param {Object} res - Express response object
+ */
+router.get('/', async (req, res) => {
+ try {
+ const tags = await getConversationTags(req.user.id);
+ if (tags) {
+ res.status(200).json(tags);
+ } else {
+ res.status(404).end();
+ }
+ } catch (error) {
+ logger.error('Error getting conversation tags:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * POST /
+ * Creates a new conversation tag for the authenticated user.
+ * @param {Object} req - Express request object
+ * @param {Object} res - Express response object
+ */
+router.post('/', async (req, res) => {
+ try {
+ const tag = await createConversationTag(req.user.id, req.body);
+ res.status(200).json(tag);
+ } catch (error) {
+ logger.error('Error creating conversation tag:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * PUT /:tag
+ * Updates an existing conversation tag for the authenticated user.
+ * @param {Object} req - Express request object
+ * @param {Object} res - Express response object
+ */
+router.put('/:tag', async (req, res) => {
+ try {
+ const decodedTag = decodeURIComponent(req.params.tag);
+ const tag = await updateConversationTag(req.user.id, decodedTag, req.body);
+ if (tag) {
+ res.status(200).json(tag);
+ } else {
+ res.status(404).json({ error: 'Tag not found' });
+ }
+ } catch (error) {
+ logger.error('Error updating conversation tag:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * DELETE /:tag
+ * Deletes a conversation tag for the authenticated user.
+ * @param {Object} req - Express request object
+ * @param {Object} res - Express response object
+ */
+router.delete('/:tag', async (req, res) => {
+ try {
+ const decodedTag = decodeURIComponent(req.params.tag);
+ const tag = await deleteConversationTag(req.user.id, decodedTag);
+ if (tag) {
+ res.status(200).json(tag);
+ } else {
+ res.status(404).json({ error: 'Tag not found' });
+ }
+ } catch (error) {
+ logger.error('Error deleting conversation tag:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+/**
+ * PUT /convo/:conversationId
+ * Updates the tags for a conversation.
+ * @param {Object} req - Express request object
+ * @param {Object} res - Express response object
+ */
+router.put('/convo/:conversationId', async (req, res) => {
+ try {
+ const conversationTags = await updateTagsForConversation(
+ req.user.id,
+ req.params.conversationId,
+ req.body.tags,
+ );
+ res.status(200).json(conversationTags);
+ } catch (error) {
+ logger.error('Error updating conversation tags', error);
+ res.status(500).send('Error updating conversation tags');
+ }
+});
+
+module.exports = router;
diff --git a/api/server/routes/types/assistants.js b/api/server/routes/types/assistants.js
new file mode 100644
index 0000000000..974bf587a8
--- /dev/null
+++ b/api/server/routes/types/assistants.js
@@ -0,0 +1,53 @@
+/**
+ * Enum for the possible tools that can be enabled on an assistant.
+ * @readonly
+ * @enum {string}
+ */
+// eslint-disable-next-line no-unused-vars
+const Tools = {
+ code_interpreter: 'code_interpreter',
+ retrieval: 'retrieval',
+ function: 'function',
+};
+
+/**
+ * Represents a tool with its type.
+ * @typedef {Object} Tool
+ * @property {Tools} toolName - The name of the tool and its corresponding type from the Tools enum.
+ */
+
+/**
+ * @typedef {Object} Assistant
+ * @property {string} id - The identifier, which can be referenced in API endpoints.
+ * @property {number} created_at - The Unix timestamp (in seconds) for when the assistant was created.
+ * @property {string|null} description - The maximum length is 512 characters.
+ * @property {Array} file_ids - A list of file IDs attached to this assistant.
+ * @property {string|null} instructions - The system instructions that the assistant uses. The maximum length is 32768 characters.
+ * @property {Object|null} metadata - Set of 16 key-value pairs that can be attached to an object.
+ * @property {string} model - ID of the model to use.
+ * @property {string|null} name - The name of the assistant. The maximum length is 256 characters.
+ * @property {string} object - The object type, which is always 'assistant'.
+ * @property {Tool[]} tools - A list of tools enabled on the assistant.
+ */
+
+/**
+ * @typedef {Object} AssistantCreateParams
+ * @property {string} model - ID of the model to use.
+ * @property {string|null} [description] - The description of the assistant.
+ * @property {Array} [file_ids] - A list of file IDs attached to this assistant.
+ * @property {string|null} [instructions] - The system instructions that the assistant uses.
+ * @property {Object|null} [metadata] - Set of 16 key-value pairs that can be attached to an object.
+ * @property {string|null} [name] - The name of the assistant.
+ * @property {Tool[]} tools - A list of tools enabled on the assistant.
+ */
+
+/**
+ * @typedef {Object} AssistantUpdateParams
+ * // Similar properties to AssistantCreateParams, but all optional
+ */
+
+/**
+ * @typedef {Object} AssistantListParams
+ * @property {string|null} [before] - A cursor for use in pagination.
+ * @property {'asc'|'desc'} [order] - Sort order by the created_at timestamp of the objects.
+ */
diff --git a/api/server/routes/user.js b/api/server/routes/user.js
new file mode 100644
index 0000000000..1858be22be
--- /dev/null
+++ b/api/server/routes/user.js
@@ -0,0 +1,31 @@
+const express = require('express');
+const {
+ updateUserPluginsController,
+ resendVerificationController,
+ getTermsStatusController,
+ acceptTermsController,
+ verifyEmailController,
+ deleteUserController,
+ getUserController,
+} = require('~/server/controllers/UserController');
+const {
+ verifyEmailLimiter,
+ configMiddleware,
+ canDeleteAccount,
+ requireJwtAuth,
+} = require('~/server/middleware');
+
+const settings = require('./settings');
+
+const router = express.Router();
+
+router.use('/settings', settings);
+router.get('/', requireJwtAuth, getUserController);
+router.get('/terms', requireJwtAuth, getTermsStatusController);
+router.post('/terms/accept', requireJwtAuth, acceptTermsController);
+router.post('/plugins', requireJwtAuth, updateUserPluginsController);
+router.delete('/delete', requireJwtAuth, canDeleteAccount, configMiddleware, deleteUserController);
+router.post('/verify', verifyEmailController);
+router.post('/verify/resend', verifyEmailLimiter, resendVerificationController);
+
+module.exports = router;
diff --git a/api/server/services/ActionService.js b/api/server/services/ActionService.js
new file mode 100644
index 0000000000..79586f0cf2
--- /dev/null
+++ b/api/server/services/ActionService.js
@@ -0,0 +1,443 @@
+const jwt = require('jsonwebtoken');
+const { nanoid } = require('nanoid');
+const { tool } = require('@langchain/core/tools');
+const { GraphEvents, sleep } = require('@librechat/agents');
+const { logger, encryptV2, decryptV2 } = require('@librechat/data-schemas');
+const { sendEvent, logAxiosError, refreshAccessToken } = require('@librechat/api');
+const {
+ Time,
+ CacheKeys,
+ StepTypes,
+ Constants,
+ AuthTypeEnum,
+ actionDelimiter,
+ isImageVisionTool,
+ actionDomainSeparator,
+} = require('librechat-data-provider');
+const { findToken, updateToken, createToken } = require('~/models');
+const { getActions, deleteActions } = require('~/models/Action');
+const { deleteAssistant } = require('~/models/Assistant');
+const { getFlowStateManager } = require('~/config');
+const { getLogStores } = require('~/cache');
+
+const JWT_SECRET = process.env.JWT_SECRET;
+const toolNameRegex = /^[a-zA-Z0-9_-]+$/;
+const replaceSeparatorRegex = new RegExp(actionDomainSeparator, 'g');
+
+/**
+ * Validates tool name against regex pattern and updates if necessary.
+ * @param {object} params - The parameters for the function.
+ * @param {object} params.req - Express Request.
+ * @param {FunctionTool} params.tool - The tool object.
+ * @param {string} params.assistant_id - The assistant ID
+ * @returns {object|null} - Updated tool object or null if invalid and not an action.
+ */
+const validateAndUpdateTool = async ({ req, tool, assistant_id }) => {
+ let actions;
+ if (isImageVisionTool(tool)) {
+ return null;
+ }
+ if (!toolNameRegex.test(tool.function.name)) {
+ const [functionName, domain] = tool.function.name.split(actionDelimiter);
+ actions = await getActions({ assistant_id, user: req.user.id }, true);
+ const matchingActions = actions.filter((action) => {
+ const metadata = action.metadata;
+ return metadata && metadata.domain === domain;
+ });
+ const action = matchingActions[0];
+ if (!action) {
+ return null;
+ }
+
+ const parsedDomain = await domainParser(domain, true);
+
+ if (!parsedDomain) {
+ return null;
+ }
+
+ tool.function.name = `${functionName}${actionDelimiter}${parsedDomain}`;
+ }
+ return tool;
+};
+
+/**
+ * Encodes or decodes a domain name to/from base64, or replacing periods with a custom separator.
+ *
+ * Necessary due to `[a-zA-Z0-9_-]*` Regex Validation, limited to a 64-character maximum.
+ *
+ * @param {string} domain - The domain name to encode/decode.
+ * @param {boolean} inverse - False to decode from base64, true to encode to base64.
+ * @returns {Promise} Encoded or decoded domain string.
+ */
+async function domainParser(domain, inverse = false) {
+ if (!domain) {
+ return;
+ }
+ const domainsCache = getLogStores(CacheKeys.ENCODED_DOMAINS);
+ const cachedDomain = await domainsCache.get(domain);
+ if (inverse && cachedDomain) {
+ return domain;
+ }
+
+ if (inverse && domain.length <= Constants.ENCODED_DOMAIN_LENGTH) {
+ return domain.replace(/\./g, actionDomainSeparator);
+ }
+
+ if (inverse) {
+ const modifiedDomain = Buffer.from(domain).toString('base64');
+ const key = modifiedDomain.substring(0, Constants.ENCODED_DOMAIN_LENGTH);
+ await domainsCache.set(key, modifiedDomain);
+ return key;
+ }
+
+ if (!cachedDomain) {
+ return domain.replace(replaceSeparatorRegex, '.');
+ }
+
+ try {
+ return Buffer.from(cachedDomain, 'base64').toString('utf-8');
+ } catch (error) {
+ logger.error(`Failed to parse domain (possibly not base64): ${domain}`, error);
+ return domain;
+ }
+}
+
+/**
+ * Loads action sets based on the user and assistant ID.
+ *
+ * @param {Object} searchParams - The parameters for loading action sets.
+ * @param {string} searchParams.user - The user identifier.
+ * @param {string} [searchParams.agent_id]- The agent identifier.
+ * @param {string} [searchParams.assistant_id]- The assistant identifier.
+ * @returns {Promise} A promise that resolves to an array of actions or `null` if no match.
+ */
+async function loadActionSets(searchParams) {
+ return await getActions(searchParams, true);
+}
+
+/**
+ * Creates a general tool for an entire action set.
+ *
+ * @param {Object} params - The parameters for loading action sets.
+ * @param {string} params.userId
+ * @param {ServerResponse} params.res
+ * @param {Action} params.action - The action set. Necessary for decrypting authentication values.
+ * @param {ActionRequest} params.requestBuilder - The ActionRequest builder class to execute the API call.
+ * @param {string | undefined} [params.name] - The name of the tool.
+ * @param {string | undefined} [params.description] - The description for the tool.
+ * @param {import('zod').ZodTypeAny | undefined} [params.zodSchema] - The Zod schema for tool input validation/definition
+ * @param {{ oauth_client_id?: string; oauth_client_secret?: string; }} params.encrypted - The encrypted values for the action.
+ * @returns { Promise unknown}> } An object with `_call` method to execute the tool input.
+ */
+async function createActionTool({
+ userId,
+ res,
+ action,
+ requestBuilder,
+ zodSchema,
+ name,
+ description,
+ encrypted,
+}) {
+ /** @type {(toolInput: Object | string, config: GraphRunnableConfig) => Promise