Compare commits
102 Commits
feat/Custo
...
refactor/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b8b1c78da | ||
|
|
02b9c9d447 | ||
|
|
49490984d1 | ||
|
|
f68be4727c | ||
|
|
0103b4b08a | ||
|
|
5eb0703f78 | ||
|
|
4419e2c294 | ||
|
|
5f2d1c5dc9 | ||
|
|
46ff008b07 | ||
|
|
55f79bd2d1 | ||
|
|
1bd874591a | ||
|
|
6488873bad | ||
|
|
13c7ceb918 | ||
|
|
cdf42b3a03 | ||
|
|
c2a18f61b4 | ||
|
|
a57224c1d5 | ||
|
|
118ad943c9 | ||
|
|
272522452a | ||
|
|
b0054c775a | ||
|
|
9bb9aba8ec | ||
|
|
293ac02b95 | ||
|
|
29ef91b4dd | ||
|
|
cd7dd576c1 | ||
|
|
c22d74d41e | ||
|
|
2c39ccd2af | ||
|
|
53df6a1a71 | ||
|
|
dff4fcac00 | ||
|
|
be4cf5846c | ||
|
|
6bb78247b3 | ||
|
|
cbddd394a5 | ||
|
|
830be18b90 | ||
|
|
32bab33499 | ||
|
|
1806b70418 | ||
|
|
5ccdb83e5e | ||
|
|
e77aa92a7b | ||
|
|
8cade2120d | ||
|
|
c7f2ee36c5 | ||
|
|
f2f4bf87ca | ||
|
|
442b149d55 | ||
|
|
aca89091d9 | ||
|
|
37c94beeac | ||
|
|
80bc49db8d | ||
|
|
d3a504857a | ||
|
|
09e3500d39 | ||
|
|
8458401ce6 | ||
|
|
f9d40784f0 | ||
|
|
a2fc7d312a | ||
|
|
4cbab86b45 | ||
|
|
4808c5be48 | ||
|
|
c517f668fc | ||
|
|
939b4ce659 | ||
|
|
87255dac81 | ||
|
|
442976c74f | ||
|
|
fb88ac00c6 | ||
|
|
b846f562be | ||
|
|
5cf86b347f | ||
|
|
f556aaeaea | ||
|
|
2f462c9b3c | ||
|
|
077b7e7e79 | ||
|
|
c68cc0a550 | ||
|
|
deb8a00e27 | ||
|
|
b45ff8e4ed | ||
|
|
fc8d24fa5b | ||
|
|
449d9b7613 | ||
|
|
ddb0a7a216 | ||
|
|
b2f44fc90f | ||
|
|
cede5d120c | ||
|
|
ed9ab8842a | ||
|
|
b344ed12a1 | ||
|
|
afee1a2cbd | ||
|
|
0dbbf7de04 | ||
|
|
bf80cf30b3 | ||
|
|
d47d827ed9 | ||
|
|
5be446edff | ||
|
|
2265413387 | ||
|
|
7e98702a87 | ||
|
|
a2f330e6ca | ||
|
|
28b76ce339 | ||
|
|
eb1668ff22 | ||
|
|
e86842fd19 | ||
|
|
af96666ff4 | ||
|
|
59109cd2dd | ||
|
|
c8f5f5131e | ||
|
|
8c0be0e2f0 | ||
|
|
f8cb0cdcda | ||
|
|
55d52d07f2 | ||
|
|
7ce782fec6 | ||
|
|
c79ee32006 | ||
|
|
739b0d3012 | ||
|
|
9c9fe4e03a | ||
|
|
844bbbb162 | ||
|
|
26780bddf0 | ||
|
|
353adceb0c | ||
|
|
a92ac23c44 | ||
|
|
2a3bf259aa | ||
|
|
3152a1e536 | ||
|
|
2f4a03b581 | ||
|
|
7a91f6ca62 | ||
|
|
fe311df969 | ||
|
|
535e7798b3 | ||
|
|
621fa6e1aa | ||
|
|
f6cc394eab |
95
.env.example
95
.env.example
@@ -20,8 +20,8 @@ DOMAIN_CLIENT=http://localhost:3080
|
|||||||
DOMAIN_SERVER=http://localhost:3080
|
DOMAIN_SERVER=http://localhost:3080
|
||||||
|
|
||||||
NO_INDEX=true
|
NO_INDEX=true
|
||||||
# Use the address that is at most n number of hops away from the Express application.
|
# 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.
|
# 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.
|
# 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.
|
# Defaulted to 1.
|
||||||
TRUST_PROXY=1
|
TRUST_PROXY=1
|
||||||
@@ -88,7 +88,7 @@ PROXY=
|
|||||||
#============#
|
#============#
|
||||||
|
|
||||||
ANTHROPIC_API_KEY=user_provided
|
ANTHROPIC_API_KEY=user_provided
|
||||||
# ANTHROPIC_MODELS=claude-3-7-sonnet-latest,claude-3-7-sonnet-20250219,claude-3-5-haiku-20241022,claude-3-5-sonnet-20241022,claude-3-5-sonnet-latest,claude-3-5-sonnet-20240620,claude-3-opus-20240229,claude-3-sonnet-20240229,claude-3-haiku-20240307,claude-2.1,claude-2,claude-1.2,claude-1,claude-1-100k,claude-instant-1,claude-instant-1-100k
|
# 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=
|
# ANTHROPIC_REVERSE_PROXY=
|
||||||
|
|
||||||
#============#
|
#============#
|
||||||
@@ -443,6 +443,47 @@ OPENID_IMAGE_URL=
|
|||||||
# Set to true to automatically redirect to the OpenID provider when a user visits the login page
|
# 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
|
# This will bypass the login form completely for users, only use this if OpenID is your only authentication method
|
||||||
OPENID_AUTO_REDIRECT=false
|
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_USERINFRO_REQUIRED=
|
||||||
|
OPENID_ON_BEHALF_FLOW_USERINFRO_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=
|
||||||
|
|
||||||
|
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
|
||||||
# LDAP
|
# LDAP
|
||||||
LDAP_URL=
|
LDAP_URL=
|
||||||
@@ -474,6 +515,18 @@ EMAIL_PASSWORD=
|
|||||||
EMAIL_FROM_NAME=
|
EMAIL_FROM_NAME=
|
||||||
EMAIL_FROM=noreply@librechat.ai
|
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 CDN #
|
||||||
#========================#
|
#========================#
|
||||||
@@ -563,9 +616,9 @@ HELP_AND_FAQ_URL=https://librechat.ai
|
|||||||
# users always get the latest version. Customize #
|
# users always get the latest version. Customize #
|
||||||
# only if you understand caching implications. #
|
# only if you understand caching implications. #
|
||||||
|
|
||||||
# INDEX_HTML_CACHE_CONTROL=no-cache, no-store, must-revalidate
|
# INDEX_CACHE_CONTROL=no-cache, no-store, must-revalidate
|
||||||
# INDEX_HTML_PRAGMA=no-cache
|
# INDEX_PRAGMA=no-cache
|
||||||
# INDEX_HTML_EXPIRES=0
|
# INDEX_EXPIRES=0
|
||||||
|
|
||||||
# no-cache: Forces validation with server before using cached version
|
# no-cache: Forces validation with server before using cached version
|
||||||
# no-store: Prevents storing the response entirely
|
# no-store: Prevents storing the response entirely
|
||||||
@@ -575,3 +628,33 @@ HELP_AND_FAQ_URL=https://librechat.ai
|
|||||||
# OpenWeather #
|
# OpenWeather #
|
||||||
#=====================================================#
|
#=====================================================#
|
||||||
OPENWEATHER_API_KEY=
|
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
|
||||||
4
.github/CONTRIBUTING.md
vendored
4
.github/CONTRIBUTING.md
vendored
@@ -30,8 +30,8 @@ Project maintainers have the right and responsibility to remove, edit, or reject
|
|||||||
2. Install typescript globally: `npm i -g typescript`.
|
2. Install typescript globally: `npm i -g typescript`.
|
||||||
3. Run `npm ci` to install dependencies.
|
3. Run `npm ci` to install dependencies.
|
||||||
4. Build the data provider: `npm run build:data-provider`.
|
4. Build the data provider: `npm run build:data-provider`.
|
||||||
5. Build MCP: `npm run build:mcp`.
|
5. Build data schemas: `npm run build:data-schemas`.
|
||||||
6. Build data schemas: `npm run build:data-schemas`.
|
6. Build API methods: `npm run build:api`.
|
||||||
7. Setup and run unit tests:
|
7. Setup and run unit tests:
|
||||||
- Copy `.env.test`: `cp api/test/.env.test.example api/test/.env.test`.
|
- Copy `.env.test`: `cp api/test/.env.test.example api/test/.env.test`.
|
||||||
- Run backend unit tests: `npm run test:api`.
|
- Run backend unit tests: `npm run test:api`.
|
||||||
|
|||||||
14
.github/workflows/backend-review.yml
vendored
14
.github/workflows/backend-review.yml
vendored
@@ -7,6 +7,7 @@ on:
|
|||||||
- release/*
|
- release/*
|
||||||
paths:
|
paths:
|
||||||
- 'api/**'
|
- 'api/**'
|
||||||
|
- 'packages/api/**'
|
||||||
jobs:
|
jobs:
|
||||||
tests_Backend:
|
tests_Backend:
|
||||||
name: Run Backend unit tests
|
name: Run Backend unit tests
|
||||||
@@ -36,12 +37,12 @@ jobs:
|
|||||||
- name: Install Data Provider Package
|
- name: Install Data Provider Package
|
||||||
run: npm run build:data-provider
|
run: npm run build:data-provider
|
||||||
|
|
||||||
- name: Install MCP Package
|
|
||||||
run: npm run build:mcp
|
|
||||||
|
|
||||||
- name: Install Data Schemas Package
|
- name: Install Data Schemas Package
|
||||||
run: npm run build:data-schemas
|
run: npm run build:data-schemas
|
||||||
|
|
||||||
|
- name: Install API Package
|
||||||
|
run: npm run build:api
|
||||||
|
|
||||||
- name: Create empty auth.json file
|
- name: Create empty auth.json file
|
||||||
run: |
|
run: |
|
||||||
mkdir -p api/data
|
mkdir -p api/data
|
||||||
@@ -66,5 +67,8 @@ jobs:
|
|||||||
- name: Run librechat-data-provider unit tests
|
- name: Run librechat-data-provider unit tests
|
||||||
run: cd packages/data-provider && npm run test:ci
|
run: cd packages/data-provider && npm run test:ci
|
||||||
|
|
||||||
- name: Run librechat-mcp unit tests
|
- name: Run @librechat/data-schemas unit tests
|
||||||
run: cd packages/mcp && npm run test:ci
|
run: cd packages/data-schemas && npm run test:ci
|
||||||
|
|
||||||
|
- name: Run @librechat/api unit tests
|
||||||
|
run: cd packages/api && npm run test:ci
|
||||||
17
.github/workflows/deploy-dev.yml
vendored
17
.github/workflows/deploy-dev.yml
vendored
@@ -2,7 +2,7 @@ name: Update Test Server
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["Docker Dev Images Build"]
|
workflows: ["Docker Dev Branch Images Build"]
|
||||||
types:
|
types:
|
||||||
- completed
|
- completed
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -12,7 +12,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: |
|
if: |
|
||||||
github.repository == 'danny-avila/LibreChat' &&
|
github.repository == 'danny-avila/LibreChat' &&
|
||||||
(github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success')
|
(github.event_name == 'workflow_dispatch' ||
|
||||||
|
(github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'dev'))
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -29,13 +30,17 @@ jobs:
|
|||||||
DO_USER: ${{ secrets.DO_USER }}
|
DO_USER: ${{ secrets.DO_USER }}
|
||||||
run: |
|
run: |
|
||||||
ssh -o StrictHostKeyChecking=no ${DO_USER}@${DO_HOST} << EOF
|
ssh -o StrictHostKeyChecking=no ${DO_USER}@${DO_HOST} << EOF
|
||||||
sudo -i -u danny bash << EEOF
|
sudo -i -u danny bash << 'EEOF'
|
||||||
cd ~/LibreChat && \
|
cd ~/LibreChat && \
|
||||||
git fetch origin main && \
|
git fetch origin main && \
|
||||||
npm run update:deployed && \
|
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 checkout do-deploy && \
|
||||||
git rebase main && \
|
git rebase dev && \
|
||||||
npm run start:deployed && \
|
sudo npm run start:deployed && \
|
||||||
echo "Update completed. Application should be running now."
|
echo "Update completed. Application should be running now."
|
||||||
EEOF
|
EEOF
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
72
.github/workflows/dev-branch-images.yml
vendored
Normal file
72
.github/workflows/dev-branch-images.yml
vendored
Normal file
@@ -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 }}
|
||||||
7
.github/workflows/helmcharts.yml
vendored
7
.github/workflows/helmcharts.yml
vendored
@@ -26,8 +26,15 @@ jobs:
|
|||||||
uses: azure/setup-helm@v4
|
uses: azure/setup-helm@v4
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
- name: Build Subchart Deps
|
||||||
|
run: |
|
||||||
|
cd helm/librechat-rag-api
|
||||||
|
helm dependency build
|
||||||
|
|
||||||
- name: Run chart-releaser
|
- name: Run chart-releaser
|
||||||
uses: helm/chart-releaser-action@v1.6.0
|
uses: helm/chart-releaser-action@v1.6.0
|
||||||
|
with:
|
||||||
|
charts_dir: helm
|
||||||
|
skip_existing: true
|
||||||
env:
|
env:
|
||||||
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
|||||||
5
.github/workflows/i18n-unused-keys.yml
vendored
5
.github/workflows/i18n-unused-keys.yml
vendored
@@ -5,12 +5,13 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- "client/src/**"
|
- "client/src/**"
|
||||||
- "api/**"
|
- "api/**"
|
||||||
|
- "packages/data-provider/src/**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
detect-unused-i18n-keys:
|
detect-unused-i18n-keys:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write # Required for posting PR comments
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
@@ -22,7 +23,7 @@ jobs:
|
|||||||
|
|
||||||
# Define paths
|
# Define paths
|
||||||
I18N_FILE="client/src/locales/en/translation.json"
|
I18N_FILE="client/src/locales/en/translation.json"
|
||||||
SOURCE_DIRS=("client/src" "api")
|
SOURCE_DIRS=("client/src" "api" "packages/data-provider/src")
|
||||||
|
|
||||||
# Check if translation file exists
|
# Check if translation file exists
|
||||||
if [[ ! -f "$I18N_FILE" ]]; then
|
if [[ ! -f "$I18N_FILE" ]]; then
|
||||||
|
|||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -52,8 +52,9 @@ bower_components/
|
|||||||
*.d.ts
|
*.d.ts
|
||||||
!vite-env.d.ts
|
!vite-env.d.ts
|
||||||
|
|
||||||
# Cline
|
# AI
|
||||||
.clineignore
|
.clineignore
|
||||||
|
.cursor
|
||||||
|
|
||||||
# Floobits
|
# Floobits
|
||||||
.floo
|
.floo
|
||||||
@@ -113,4 +114,13 @@ uploads/
|
|||||||
|
|
||||||
# owner
|
# owner
|
||||||
release/
|
release/
|
||||||
|
|
||||||
|
# Helm
|
||||||
|
helm/librechat/Chart.lock
|
||||||
|
helm/**/charts/
|
||||||
|
helm/**/.values.yaml
|
||||||
|
|
||||||
!/client/src/@types/i18next.d.ts
|
!/client/src/@types/i18next.d.ts
|
||||||
|
|
||||||
|
# SAML Idp cert
|
||||||
|
*.cert
|
||||||
|
|||||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -5,23 +5,47 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### ✨ New Features
|
### ✨ New Features
|
||||||
|
|
||||||
- ✨ feat: implement search parameter updates by **@mawburn** in [#7151](https://github.com/danny-avila/LibreChat/pull/7151)
|
- ✨ 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 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
|
### 🔧 Fixes
|
||||||
|
|
||||||
- 💬 fix: update aria-label for accessibility in ConvoLink component by **@berry-13** in [#7320](https://github.com/danny-avila/LibreChat/pull/7320)
|
- 💬 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: 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: 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
|
### ⚙️ Other Changes
|
||||||
|
|
||||||
- 📜 docs: CHANGELOG for release v0.7.8 by **@github-actions[bot]** in [#7290](https://github.com/danny-avila/LibreChat/pull/7290)
|
- 📜 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)
|
- 📦 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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -67,7 +91,6 @@ Changes from v0.7.8-rc1 to v0.7.8.
|
|||||||
|
|
||||||
---
|
---
|
||||||
## [v0.7.8-rc1] -
|
## [v0.7.8-rc1] -
|
||||||
## [v0.7.8-rc1] -
|
|
||||||
|
|
||||||
Changes from v0.7.7 to v0.7.8-rc1.
|
Changes from v0.7.7 to v0.7.8-rc1.
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ RUN npm config set fetch-retry-maxtimeout 600000 && \
|
|||||||
npm config set fetch-retry-mintimeout 15000
|
npm config set fetch-retry-mintimeout 15000
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
COPY packages/data-provider/package*.json ./packages/data-provider/
|
COPY packages/data-provider/package*.json ./packages/data-provider/
|
||||||
COPY packages/mcp/package*.json ./packages/mcp/
|
COPY packages/api/package*.json ./packages/api/
|
||||||
COPY packages/data-schemas/package*.json ./packages/data-schemas/
|
COPY packages/data-schemas/package*.json ./packages/data-schemas/
|
||||||
COPY client/package*.json ./client/
|
COPY client/package*.json ./client/
|
||||||
COPY api/package*.json ./api/
|
COPY api/package*.json ./api/
|
||||||
@@ -24,26 +24,27 @@ FROM base-min AS base
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
# Build data-provider
|
# Build `data-provider` package
|
||||||
FROM base AS data-provider-build
|
FROM base AS data-provider-build
|
||||||
WORKDIR /app/packages/data-provider
|
WORKDIR /app/packages/data-provider
|
||||||
COPY packages/data-provider ./
|
COPY packages/data-provider ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Build mcp package
|
# Build `data-schemas` package
|
||||||
FROM base AS mcp-build
|
|
||||||
WORKDIR /app/packages/mcp
|
|
||||||
COPY packages/mcp ./
|
|
||||||
COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Build data-schemas
|
|
||||||
FROM base AS data-schemas-build
|
FROM base AS data-schemas-build
|
||||||
WORKDIR /app/packages/data-schemas
|
WORKDIR /app/packages/data-schemas
|
||||||
COPY packages/data-schemas ./
|
COPY packages/data-schemas ./
|
||||||
COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist
|
COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist
|
||||||
RUN npm run build
|
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
|
||||||
|
|
||||||
# Client build
|
# Client build
|
||||||
FROM base AS client-build
|
FROM base AS client-build
|
||||||
WORKDIR /app/client
|
WORKDIR /app/client
|
||||||
@@ -63,8 +64,8 @@ RUN npm ci --omit=dev
|
|||||||
COPY api ./api
|
COPY api ./api
|
||||||
COPY config ./config
|
COPY config ./config
|
||||||
COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data-provider/dist
|
COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data-provider/dist
|
||||||
COPY --from=mcp-build /app/packages/mcp/dist ./packages/mcp/dist
|
|
||||||
COPY --from=data-schemas-build /app/packages/data-schemas/dist ./packages/data-schemas/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
|
COPY --from=client-build /app/client/dist ./client/dist
|
||||||
WORKDIR /app/api
|
WORKDIR /app/api
|
||||||
EXPOSE 3080
|
EXPOSE 3080
|
||||||
|
|||||||
@@ -71,6 +71,11 @@
|
|||||||
- [Model Context Protocol (MCP) Support](https://modelcontextprotocol.io/clients#librechat) for Tools
|
- [Model Context Protocol (MCP) Support](https://modelcontextprotocol.io/clients#librechat) for Tools
|
||||||
- Use LibreChat Agents and OpenAI Assistants with Files, Code Interpreter, Tools, and API Actions
|
- Use LibreChat Agents and OpenAI Assistants with Files, Code Interpreter, Tools, and API Actions
|
||||||
|
|
||||||
|
- 🔍 **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
|
||||||
|
- **[Learn More →](https://www.librechat.ai/docs/features/web_search)**
|
||||||
|
|
||||||
- 🪄 **Generative UI with Code Artifacts**:
|
- 🪄 **Generative UI with Code Artifacts**:
|
||||||
- [Code Artifacts](https://youtu.be/GfTj7O4gmd0?si=WJbdnemZpJzBrJo3) allow creation of React, HTML, and Mermaid diagrams directly in chat
|
- [Code Artifacts](https://youtu.be/GfTj7O4gmd0?si=WJbdnemZpJzBrJo3) allow creation of React, HTML, and Mermaid diagrams directly in chat
|
||||||
|
|
||||||
@@ -145,8 +150,8 @@ Click on the thumbnail to open the video☝️
|
|||||||
|
|
||||||
**Other:**
|
**Other:**
|
||||||
- **Website:** [librechat.ai](https://librechat.ai)
|
- **Website:** [librechat.ai](https://librechat.ai)
|
||||||
- **Documentation:** [docs.librechat.ai](https://docs.librechat.ai)
|
- **Documentation:** [librechat.ai/docs](https://librechat.ai/docs)
|
||||||
- **Blog:** [blog.librechat.ai](https://blog.librechat.ai)
|
- **Blog:** [librechat.ai/blog](https://librechat.ai/blog)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const {
|
|||||||
validateVisionModel,
|
validateVisionModel,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { SplitStreamHandler: _Handler } = require('@librechat/agents');
|
const { SplitStreamHandler: _Handler } = require('@librechat/agents');
|
||||||
|
const { Tokenizer, createFetch, createStreamEventHandlers } = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
truncateText,
|
truncateText,
|
||||||
formatMessage,
|
formatMessage,
|
||||||
@@ -26,8 +27,6 @@ const {
|
|||||||
const { getModelMaxTokens, getModelMaxOutputTokens, matchModelName } = require('~/utils');
|
const { getModelMaxTokens, getModelMaxOutputTokens, matchModelName } = require('~/utils');
|
||||||
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
||||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||||
const { createFetch, createStreamEventHandlers } = require('./generators');
|
|
||||||
const Tokenizer = require('~/server/services/Tokenizer');
|
|
||||||
const { sleep } = require('~/server/utils');
|
const { sleep } = require('~/server/utils');
|
||||||
const BaseClient = require('./BaseClient');
|
const BaseClient = require('./BaseClient');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
@@ -70,13 +69,10 @@ class AnthropicClient extends BaseClient {
|
|||||||
this.message_delta;
|
this.message_delta;
|
||||||
/** Whether the model is part of the Claude 3 Family
|
/** Whether the model is part of the Claude 3 Family
|
||||||
* @type {boolean} */
|
* @type {boolean} */
|
||||||
this.isClaude3;
|
this.isClaudeLatest;
|
||||||
/** Whether to use Messages API or Completions API
|
/** Whether to use Messages API or Completions API
|
||||||
* @type {boolean} */
|
* @type {boolean} */
|
||||||
this.useMessages;
|
this.useMessages;
|
||||||
/** Whether or not the model is limited to the legacy amount of output tokens
|
|
||||||
* @type {boolean} */
|
|
||||||
this.isLegacyOutput;
|
|
||||||
/** Whether or not the model supports Prompt Caching
|
/** Whether or not the model supports Prompt Caching
|
||||||
* @type {boolean} */
|
* @type {boolean} */
|
||||||
this.supportsCacheControl;
|
this.supportsCacheControl;
|
||||||
@@ -116,21 +112,25 @@ class AnthropicClient extends BaseClient {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const modelMatch = matchModelName(this.modelOptions.model, EModelEndpoint.anthropic);
|
const modelMatch = matchModelName(this.modelOptions.model, EModelEndpoint.anthropic);
|
||||||
this.isClaude3 = modelMatch.includes('claude-3');
|
this.isClaudeLatest =
|
||||||
this.isLegacyOutput = !(
|
/claude-[3-9]/.test(modelMatch) || /claude-(?:sonnet|opus|haiku)-[4-9]/.test(modelMatch);
|
||||||
/claude-3[-.]5-sonnet/.test(modelMatch) || /claude-3[-.]7/.test(modelMatch)
|
const isLegacyOutput = !(
|
||||||
|
/claude-3[-.]5-sonnet/.test(modelMatch) ||
|
||||||
|
/claude-3[-.]7/.test(modelMatch) ||
|
||||||
|
/claude-(?:sonnet|opus|haiku)-[4-9]/.test(modelMatch) ||
|
||||||
|
/claude-[4-9]/.test(modelMatch)
|
||||||
);
|
);
|
||||||
this.supportsCacheControl = this.options.promptCache && checkPromptCacheSupport(modelMatch);
|
this.supportsCacheControl = this.options.promptCache && checkPromptCacheSupport(modelMatch);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.isLegacyOutput &&
|
isLegacyOutput &&
|
||||||
this.modelOptions.maxOutputTokens &&
|
this.modelOptions.maxOutputTokens &&
|
||||||
this.modelOptions.maxOutputTokens > legacy.maxOutputTokens.default
|
this.modelOptions.maxOutputTokens > legacy.maxOutputTokens.default
|
||||||
) {
|
) {
|
||||||
this.modelOptions.maxOutputTokens = legacy.maxOutputTokens.default;
|
this.modelOptions.maxOutputTokens = legacy.maxOutputTokens.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.useMessages = this.isClaude3 || !!this.options.attachments;
|
this.useMessages = this.isClaudeLatest || !!this.options.attachments;
|
||||||
|
|
||||||
this.defaultVisionModel = this.options.visionModel ?? 'claude-3-sonnet-20240229';
|
this.defaultVisionModel = this.options.visionModel ?? 'claude-3-sonnet-20240229';
|
||||||
this.options.attachments?.then((attachments) => this.checkVisionRequest(attachments));
|
this.options.attachments?.then((attachments) => this.checkVisionRequest(attachments));
|
||||||
@@ -654,7 +654,10 @@ class AnthropicClient extends BaseClient {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.modelOptions.model.includes('claude-3')) {
|
if (
|
||||||
|
/claude-[3-9]/.test(this.modelOptions.model) ||
|
||||||
|
/claude-(?:sonnet|opus|haiku)-[4-9]/.test(this.modelOptions.model)
|
||||||
|
) {
|
||||||
await buildMessagesPayload();
|
await buildMessagesPayload();
|
||||||
processTokens();
|
processTokens();
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const { Keyv } = require('keyv');
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { CohereClient } = require('cohere-ai');
|
const { CohereClient } = require('cohere-ai');
|
||||||
const { fetchEventSource } = require('@waylaidwanderer/fetch-event-source');
|
const { fetchEventSource } = require('@waylaidwanderer/fetch-event-source');
|
||||||
|
const { constructAzureURL, genAzureChatCompletion } = require('@librechat/api');
|
||||||
const { encoding_for_model: encodingForModel, get_encoding: getEncoding } = require('tiktoken');
|
const { encoding_for_model: encodingForModel, get_encoding: getEncoding } = require('tiktoken');
|
||||||
const {
|
const {
|
||||||
ImageDetail,
|
ImageDetail,
|
||||||
@@ -10,9 +11,9 @@ const {
|
|||||||
CohereConstants,
|
CohereConstants,
|
||||||
mapModelToAzureConfig,
|
mapModelToAzureConfig,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { extractBaseURL, constructAzureURL, genAzureChatCompletion } = require('~/utils');
|
|
||||||
const { createContextHandlers } = require('./prompts');
|
const { createContextHandlers } = require('./prompts');
|
||||||
const { createCoherePayload } = require('./llm');
|
const { createCoherePayload } = require('./llm');
|
||||||
|
const { extractBaseURL } = require('~/utils');
|
||||||
const BaseClient = require('./BaseClient');
|
const BaseClient = require('./BaseClient');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
@@ -244,9 +245,9 @@ class ChatGPTClient extends BaseClient {
|
|||||||
|
|
||||||
baseURL = this.langchainProxy
|
baseURL = this.langchainProxy
|
||||||
? constructAzureURL({
|
? constructAzureURL({
|
||||||
baseURL: this.langchainProxy,
|
baseURL: this.langchainProxy,
|
||||||
azureOptions: this.azure,
|
azureOptions: this.azure,
|
||||||
})
|
})
|
||||||
: this.azureEndpoint.split(/(?<!\/)\/(chat|completion)\//)[0];
|
: this.azureEndpoint.split(/(?<!\/)\/(chat|completion)\//)[0];
|
||||||
|
|
||||||
if (this.options.forcePrompt) {
|
if (this.options.forcePrompt) {
|
||||||
@@ -339,7 +340,6 @@ class ChatGPTClient extends BaseClient {
|
|||||||
opts.body = JSON.stringify(modelOptions);
|
opts.body = JSON.stringify(modelOptions);
|
||||||
|
|
||||||
if (modelOptions.stream) {
|
if (modelOptions.stream) {
|
||||||
|
|
||||||
return new Promise(async (resolve, reject) => {
|
return new Promise(async (resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
let done = false;
|
let done = false;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const { google } = require('googleapis');
|
const { google } = require('googleapis');
|
||||||
|
const { Tokenizer } = require('@librechat/api');
|
||||||
const { concat } = require('@langchain/core/utils/stream');
|
const { concat } = require('@langchain/core/utils/stream');
|
||||||
const { ChatVertexAI } = require('@langchain/google-vertexai');
|
const { ChatVertexAI } = require('@langchain/google-vertexai');
|
||||||
const { ChatGoogleGenerativeAI } = require('@langchain/google-genai');
|
const { ChatGoogleGenerativeAI } = require('@langchain/google-genai');
|
||||||
@@ -19,7 +20,6 @@ const {
|
|||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { getSafetySettings } = require('~/server/services/Endpoints/google/llm');
|
const { getSafetySettings } = require('~/server/services/Endpoints/google/llm');
|
||||||
const { encodeAndFormat } = require('~/server/services/Files/images');
|
const { encodeAndFormat } = require('~/server/services/Files/images');
|
||||||
const Tokenizer = require('~/server/services/Tokenizer');
|
|
||||||
const { spendTokens } = require('~/models/spendTokens');
|
const { spendTokens } = require('~/models/spendTokens');
|
||||||
const { getModelMaxTokens } = require('~/utils');
|
const { getModelMaxTokens } = require('~/utils');
|
||||||
const { sleep } = require('~/server/utils');
|
const { sleep } = require('~/server/utils');
|
||||||
@@ -34,7 +34,8 @@ const BaseClient = require('./BaseClient');
|
|||||||
|
|
||||||
const loc = process.env.GOOGLE_LOC || 'us-central1';
|
const loc = process.env.GOOGLE_LOC || 'us-central1';
|
||||||
const publisher = 'google';
|
const publisher = 'google';
|
||||||
const endpointPrefix = `${loc}-aiplatform.googleapis.com`;
|
const endpointPrefix =
|
||||||
|
loc === 'global' ? 'aiplatform.googleapis.com' : `${loc}-aiplatform.googleapis.com`;
|
||||||
|
|
||||||
const settings = endpointSettings[EModelEndpoint.google];
|
const settings = endpointSettings[EModelEndpoint.google];
|
||||||
const EXCLUDED_GENAI_MODELS = /gemini-(?:1\.0|1-0|pro)/;
|
const EXCLUDED_GENAI_MODELS = /gemini-(?:1\.0|1-0|pro)/;
|
||||||
@@ -236,11 +237,11 @@ class GoogleClient extends BaseClient {
|
|||||||
msg.content = (
|
msg.content = (
|
||||||
!Array.isArray(msg.content)
|
!Array.isArray(msg.content)
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
type: ContentTypes.TEXT,
|
type: ContentTypes.TEXT,
|
||||||
[ContentTypes.TEXT]: msg.content,
|
[ContentTypes.TEXT]: msg.content,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: msg.content
|
: msg.content
|
||||||
).concat(message.image_urls);
|
).concat(message.image_urls);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
const { z } = require('zod');
|
const { z } = require('zod');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { Ollama } = require('ollama');
|
const { Ollama } = require('ollama');
|
||||||
|
const { sleep } = require('@librechat/agents');
|
||||||
|
const { logAxiosError } = require('@librechat/api');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { Constants } = require('librechat-data-provider');
|
const { Constants } = require('librechat-data-provider');
|
||||||
const { deriveBaseURL, logAxiosError } = require('~/utils');
|
const { deriveBaseURL } = require('~/utils');
|
||||||
const { sleep } = require('~/server/utils');
|
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const ollamaPayloadSchema = z.object({
|
const ollamaPayloadSchema = z.object({
|
||||||
mirostat: z.number().optional(),
|
mirostat: z.number().optional(),
|
||||||
@@ -67,7 +68,7 @@ class OllamaClient {
|
|||||||
return models;
|
return models;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const logMessage =
|
const logMessage =
|
||||||
'Failed to fetch models from Ollama API. If you are not using Ollama directly, and instead, through some aggregator or reverse proxy that handles fetching via OpenAI spec, ensure the name of the endpoint doesn\'t start with `ollama` (case-insensitive).';
|
"Failed to fetch models from Ollama API. If you are not using Ollama directly, and instead, through some aggregator or reverse proxy that handles fetching via OpenAI spec, ensure the name of the endpoint doesn't start with `ollama` (case-insensitive).";
|
||||||
logAxiosError({ message: logMessage, error });
|
logAxiosError({ message: logMessage, error });
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
const { OllamaClient } = require('./OllamaClient');
|
const { OllamaClient } = require('./OllamaClient');
|
||||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||||
const { SplitStreamHandler, CustomOpenAIClient: OpenAI } = require('@librechat/agents');
|
const { SplitStreamHandler, CustomOpenAIClient: OpenAI } = require('@librechat/agents');
|
||||||
|
const {
|
||||||
|
isEnabled,
|
||||||
|
Tokenizer,
|
||||||
|
createFetch,
|
||||||
|
constructAzureURL,
|
||||||
|
genAzureChatCompletion,
|
||||||
|
createStreamEventHandlers,
|
||||||
|
} = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
Constants,
|
Constants,
|
||||||
ImageDetail,
|
ImageDetail,
|
||||||
@@ -16,13 +24,6 @@ const {
|
|||||||
validateVisionModel,
|
validateVisionModel,
|
||||||
mapModelToAzureConfig,
|
mapModelToAzureConfig,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const {
|
|
||||||
extractBaseURL,
|
|
||||||
constructAzureURL,
|
|
||||||
getModelMaxTokens,
|
|
||||||
genAzureChatCompletion,
|
|
||||||
getModelMaxOutputTokens,
|
|
||||||
} = require('~/utils');
|
|
||||||
const {
|
const {
|
||||||
truncateText,
|
truncateText,
|
||||||
formatMessage,
|
formatMessage,
|
||||||
@@ -30,10 +31,9 @@ const {
|
|||||||
titleInstruction,
|
titleInstruction,
|
||||||
createContextHandlers,
|
createContextHandlers,
|
||||||
} = require('./prompts');
|
} = require('./prompts');
|
||||||
|
const { extractBaseURL, getModelMaxTokens, getModelMaxOutputTokens } = require('~/utils');
|
||||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||||
const { createFetch, createStreamEventHandlers } = require('./generators');
|
const { addSpaceIfNeeded, sleep } = require('~/server/utils');
|
||||||
const { addSpaceIfNeeded, isEnabled, sleep } = require('~/server/utils');
|
|
||||||
const Tokenizer = require('~/server/services/Tokenizer');
|
|
||||||
const { spendTokens } = require('~/models/spendTokens');
|
const { spendTokens } = require('~/models/spendTokens');
|
||||||
const { handleOpenAIErrors } = require('./tools/util');
|
const { handleOpenAIErrors } = require('./tools/util');
|
||||||
const { createLLM, RunManager } = require('./llm');
|
const { createLLM, RunManager } = require('./llm');
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
const fetch = require('node-fetch');
|
|
||||||
const { GraphEvents } = require('@librechat/agents');
|
|
||||||
const { logger, sendEvent } = require('~/config');
|
|
||||||
const { sleep } = require('~/server/utils');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Makes a function to make HTTP request and logs the process.
|
|
||||||
* @param {Object} params
|
|
||||||
* @param {boolean} [params.directEndpoint] - Whether to use a direct endpoint.
|
|
||||||
* @param {string} [params.reverseProxyUrl] - The reverse proxy URL to use for the request.
|
|
||||||
* @returns {Promise<Response>} - A promise that resolves to the response of the fetch request.
|
|
||||||
*/
|
|
||||||
function createFetch({ directEndpoint = false, reverseProxyUrl = '' }) {
|
|
||||||
/**
|
|
||||||
* Makes an HTTP request and logs the process.
|
|
||||||
* @param {RequestInfo} url - The URL to make the request to. Can be a string or a Request object.
|
|
||||||
* @param {RequestInit} [init] - Optional init options for the request.
|
|
||||||
* @returns {Promise<Response>} - A promise that resolves to the response of the fetch request.
|
|
||||||
*/
|
|
||||||
return async (_url, init) => {
|
|
||||||
let url = _url;
|
|
||||||
if (directEndpoint) {
|
|
||||||
url = reverseProxyUrl;
|
|
||||||
}
|
|
||||||
logger.debug(`Making request to ${url}`);
|
|
||||||
if (typeof Bun !== 'undefined') {
|
|
||||||
return await fetch(url, init);
|
|
||||||
}
|
|
||||||
return await fetch(url, init);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add this at the module level outside the class
|
|
||||||
/**
|
|
||||||
* Creates event handlers for stream events that don't capture client references
|
|
||||||
* @param {Object} res - The response object to send events to
|
|
||||||
* @returns {Object} Object containing handler functions
|
|
||||||
*/
|
|
||||||
function createStreamEventHandlers(res) {
|
|
||||||
return {
|
|
||||||
[GraphEvents.ON_RUN_STEP]: (event) => {
|
|
||||||
if (res) {
|
|
||||||
sendEvent(res, event);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[GraphEvents.ON_MESSAGE_DELTA]: (event) => {
|
|
||||||
if (res) {
|
|
||||||
sendEvent(res, event);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[GraphEvents.ON_REASONING_DELTA]: (event) => {
|
|
||||||
if (res) {
|
|
||||||
sendEvent(res, event);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createHandleLLMNewToken(streamRate) {
|
|
||||||
return async () => {
|
|
||||||
if (streamRate) {
|
|
||||||
await sleep(streamRate);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
createFetch,
|
|
||||||
createHandleLLMNewToken,
|
|
||||||
createStreamEventHandlers,
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
const { ChatOpenAI } = require('@langchain/openai');
|
const { ChatOpenAI } = require('@langchain/openai');
|
||||||
const { sanitizeModelName, constructAzureURL } = require('~/utils');
|
const { isEnabled, sanitizeModelName, constructAzureURL } = require('@librechat/api');
|
||||||
const { isEnabled } = require('~/server/utils');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new instance of a language model (LLM) for chat interactions.
|
* Creates a new instance of a language model (LLM) for chat interactions.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ describe('AnthropicClient', () => {
|
|||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
isCreatedByUser: true,
|
isCreatedByUser: true,
|
||||||
text: 'What\'s up',
|
text: "What's up",
|
||||||
messageId: '3',
|
messageId: '3',
|
||||||
parentMessageId: '2',
|
parentMessageId: '2',
|
||||||
},
|
},
|
||||||
@@ -170,7 +170,7 @@ describe('AnthropicClient', () => {
|
|||||||
client.options.modelLabel = 'Claude-2';
|
client.options.modelLabel = 'Claude-2';
|
||||||
const result = await client.buildMessages(messages, parentMessageId);
|
const result = await client.buildMessages(messages, parentMessageId);
|
||||||
const { prompt } = result;
|
const { prompt } = result;
|
||||||
expect(prompt).toContain('Human\'s name: John');
|
expect(prompt).toContain("Human's name: John");
|
||||||
expect(prompt).toContain('You are Claude-2');
|
expect(prompt).toContain('You are Claude-2');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -244,6 +244,64 @@ describe('AnthropicClient', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Claude 4 model headers', () => {
|
||||||
|
it('should add "prompt-caching" beta header for claude-sonnet-4 model', () => {
|
||||||
|
const client = new AnthropicClient('test-api-key');
|
||||||
|
const modelOptions = {
|
||||||
|
model: 'claude-sonnet-4-20250514',
|
||||||
|
};
|
||||||
|
client.setOptions({ modelOptions, promptCache: true });
|
||||||
|
const anthropicClient = client.getClient(modelOptions);
|
||||||
|
expect(anthropicClient._options.defaultHeaders).toBeDefined();
|
||||||
|
expect(anthropicClient._options.defaultHeaders).toHaveProperty('anthropic-beta');
|
||||||
|
expect(anthropicClient._options.defaultHeaders['anthropic-beta']).toBe(
|
||||||
|
'prompt-caching-2024-07-31',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add "prompt-caching" beta header for claude-opus-4 model', () => {
|
||||||
|
const client = new AnthropicClient('test-api-key');
|
||||||
|
const modelOptions = {
|
||||||
|
model: 'claude-opus-4-20250514',
|
||||||
|
};
|
||||||
|
client.setOptions({ modelOptions, promptCache: true });
|
||||||
|
const anthropicClient = client.getClient(modelOptions);
|
||||||
|
expect(anthropicClient._options.defaultHeaders).toBeDefined();
|
||||||
|
expect(anthropicClient._options.defaultHeaders).toHaveProperty('anthropic-beta');
|
||||||
|
expect(anthropicClient._options.defaultHeaders['anthropic-beta']).toBe(
|
||||||
|
'prompt-caching-2024-07-31',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add "prompt-caching" beta header for claude-4-sonnet model', () => {
|
||||||
|
const client = new AnthropicClient('test-api-key');
|
||||||
|
const modelOptions = {
|
||||||
|
model: 'claude-4-sonnet-20250514',
|
||||||
|
};
|
||||||
|
client.setOptions({ modelOptions, promptCache: true });
|
||||||
|
const anthropicClient = client.getClient(modelOptions);
|
||||||
|
expect(anthropicClient._options.defaultHeaders).toBeDefined();
|
||||||
|
expect(anthropicClient._options.defaultHeaders).toHaveProperty('anthropic-beta');
|
||||||
|
expect(anthropicClient._options.defaultHeaders['anthropic-beta']).toBe(
|
||||||
|
'prompt-caching-2024-07-31',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add "prompt-caching" beta header for claude-4-opus model', () => {
|
||||||
|
const client = new AnthropicClient('test-api-key');
|
||||||
|
const modelOptions = {
|
||||||
|
model: 'claude-4-opus-20250514',
|
||||||
|
};
|
||||||
|
client.setOptions({ modelOptions, promptCache: true });
|
||||||
|
const anthropicClient = client.getClient(modelOptions);
|
||||||
|
expect(anthropicClient._options.defaultHeaders).toBeDefined();
|
||||||
|
expect(anthropicClient._options.defaultHeaders).toHaveProperty('anthropic-beta');
|
||||||
|
expect(anthropicClient._options.defaultHeaders['anthropic-beta']).toBe(
|
||||||
|
'prompt-caching-2024-07-31',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should not add beta header for claude-3-5-sonnet-latest model', () => {
|
it('should not add beta header for claude-3-5-sonnet-latest model', () => {
|
||||||
const client = new AnthropicClient('test-api-key');
|
const client = new AnthropicClient('test-api-key');
|
||||||
const modelOptions = {
|
const modelOptions = {
|
||||||
@@ -456,6 +514,34 @@ describe('AnthropicClient', () => {
|
|||||||
expect(client.modelOptions.maxOutputTokens).toBe(highTokenValue);
|
expect(client.modelOptions.maxOutputTokens).toBe(highTokenValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not cap maxOutputTokens for Claude 4 Sonnet models', () => {
|
||||||
|
const client = new AnthropicClient('test-api-key');
|
||||||
|
const highTokenValue = anthropicSettings.legacy.maxOutputTokens.default * 10; // 40,960 tokens
|
||||||
|
|
||||||
|
client.setOptions({
|
||||||
|
modelOptions: {
|
||||||
|
model: 'claude-sonnet-4-20250514',
|
||||||
|
maxOutputTokens: highTokenValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.modelOptions.maxOutputTokens).toBe(highTokenValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not cap maxOutputTokens for Claude 4 Opus models', () => {
|
||||||
|
const client = new AnthropicClient('test-api-key');
|
||||||
|
const highTokenValue = anthropicSettings.legacy.maxOutputTokens.default * 6; // 24,576 tokens (under 32K limit)
|
||||||
|
|
||||||
|
client.setOptions({
|
||||||
|
modelOptions: {
|
||||||
|
model: 'claude-opus-4-20250514',
|
||||||
|
maxOutputTokens: highTokenValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.modelOptions.maxOutputTokens).toBe(highTokenValue);
|
||||||
|
});
|
||||||
|
|
||||||
it('should cap maxOutputTokens for Claude 3.5 Haiku models', () => {
|
it('should cap maxOutputTokens for Claude 3.5 Haiku models', () => {
|
||||||
const client = new AnthropicClient('test-api-key');
|
const client = new AnthropicClient('test-api-key');
|
||||||
const highTokenValue = anthropicSettings.legacy.maxOutputTokens.default * 2;
|
const highTokenValue = anthropicSettings.legacy.maxOutputTokens.default * 2;
|
||||||
@@ -729,4 +815,223 @@ describe('AnthropicClient', () => {
|
|||||||
expect(capturedOptions).toHaveProperty('topK', 10);
|
expect(capturedOptions).toHaveProperty('topK', 10);
|
||||||
expect(capturedOptions).toHaveProperty('topP', 0.9);
|
expect(capturedOptions).toHaveProperty('topP', 0.9);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isClaudeLatest', () => {
|
||||||
|
it('should set isClaudeLatest to true for claude-3 models', () => {
|
||||||
|
const client = new AnthropicClient('test-api-key');
|
||||||
|
client.setOptions({
|
||||||
|
modelOptions: {
|
||||||
|
model: 'claude-3-sonnet-20240229',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(client.isClaudeLatest).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set isClaudeLatest to true for claude-3.5 models', () => {
|
||||||
|
const client = new AnthropicClient('test-api-key');
|
||||||
|
client.setOptions({
|
||||||
|
modelOptions: {
|
||||||
|
model: 'claude-3.5-sonnet-20240229',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(client.isClaudeLatest).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set isClaudeLatest to true for claude-sonnet-4 models', () => {
|
||||||
|
const client = new AnthropicClient('test-api-key');
|
||||||
|
client.setOptions({
|
||||||
|
modelOptions: {
|
||||||
|
model: 'claude-sonnet-4-20240229',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(client.isClaudeLatest).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set isClaudeLatest to true for claude-opus-4 models', () => {
|
||||||
|
const client = new AnthropicClient('test-api-key');
|
||||||
|
client.setOptions({
|
||||||
|
modelOptions: {
|
||||||
|
model: 'claude-opus-4-20240229',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(client.isClaudeLatest).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set isClaudeLatest to true for claude-3.5-haiku models', () => {
|
||||||
|
const client = new AnthropicClient('test-api-key');
|
||||||
|
client.setOptions({
|
||||||
|
modelOptions: {
|
||||||
|
model: 'claude-3.5-haiku-20240229',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(client.isClaudeLatest).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set isClaudeLatest to false for claude-2 models', () => {
|
||||||
|
const client = new AnthropicClient('test-api-key');
|
||||||
|
client.setOptions({
|
||||||
|
modelOptions: {
|
||||||
|
model: 'claude-2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(client.isClaudeLatest).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set isClaudeLatest to false for claude-instant models', () => {
|
||||||
|
const client = new AnthropicClient('test-api-key');
|
||||||
|
client.setOptions({
|
||||||
|
modelOptions: {
|
||||||
|
model: 'claude-instant',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(client.isClaudeLatest).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set isClaudeLatest to false for claude-sonnet-3 models', () => {
|
||||||
|
const client = new AnthropicClient('test-api-key');
|
||||||
|
client.setOptions({
|
||||||
|
modelOptions: {
|
||||||
|
model: 'claude-sonnet-3-20240229',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(client.isClaudeLatest).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set isClaudeLatest to false for claude-opus-3 models', () => {
|
||||||
|
const client = new AnthropicClient('test-api-key');
|
||||||
|
client.setOptions({
|
||||||
|
modelOptions: {
|
||||||
|
model: 'claude-opus-3-20240229',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(client.isClaudeLatest).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set isClaudeLatest to false for claude-haiku-3 models', () => {
|
||||||
|
const client = new AnthropicClient('test-api-key');
|
||||||
|
client.setOptions({
|
||||||
|
modelOptions: {
|
||||||
|
model: 'claude-haiku-3-20240229',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(client.isClaudeLatest).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('configureReasoning', () => {
|
||||||
|
it('should enable thinking for claude-opus-4 and claude-sonnet-4 models', async () => {
|
||||||
|
const client = new AnthropicClient('test-api-key');
|
||||||
|
// Create a mock async generator function
|
||||||
|
async function* mockAsyncGenerator() {
|
||||||
|
yield { type: 'message_start', message: { usage: {} } };
|
||||||
|
yield { delta: { text: 'Test response' } };
|
||||||
|
yield { type: 'message_delta', usage: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock createResponse to return the async generator
|
||||||
|
jest.spyOn(client, 'createResponse').mockImplementation(() => {
|
||||||
|
return mockAsyncGenerator();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test claude-opus-4
|
||||||
|
client.setOptions({
|
||||||
|
modelOptions: {
|
||||||
|
model: 'claude-opus-4-20250514',
|
||||||
|
},
|
||||||
|
thinking: true,
|
||||||
|
thinkingBudget: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
let capturedOptions = null;
|
||||||
|
jest.spyOn(client, 'getClient').mockImplementation((options) => {
|
||||||
|
capturedOptions = options;
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = [{ role: 'user', content: 'Test message' }];
|
||||||
|
await client.sendCompletion(payload, {});
|
||||||
|
|
||||||
|
expect(capturedOptions).toHaveProperty('thinking');
|
||||||
|
expect(capturedOptions.thinking).toEqual({
|
||||||
|
type: 'enabled',
|
||||||
|
budget_tokens: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test claude-sonnet-4
|
||||||
|
client.setOptions({
|
||||||
|
modelOptions: {
|
||||||
|
model: 'claude-sonnet-4-20250514',
|
||||||
|
},
|
||||||
|
thinking: true,
|
||||||
|
thinkingBudget: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.sendCompletion(payload, {});
|
||||||
|
|
||||||
|
expect(capturedOptions).toHaveProperty('thinking');
|
||||||
|
expect(capturedOptions.thinking).toEqual({
|
||||||
|
type: 'enabled',
|
||||||
|
budget_tokens: 2000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Claude Model Tests', () => {
|
||||||
|
it('should handle Claude 3 and 4 series models correctly', () => {
|
||||||
|
const client = new AnthropicClient('test-key');
|
||||||
|
// Claude 3 series models
|
||||||
|
const claude3Models = [
|
||||||
|
'claude-3-opus-20240229',
|
||||||
|
'claude-3-sonnet-20240229',
|
||||||
|
'claude-3-haiku-20240307',
|
||||||
|
'claude-3-5-sonnet-20240620',
|
||||||
|
'claude-3-5-haiku-20240620',
|
||||||
|
'claude-3.5-sonnet-20240620',
|
||||||
|
'claude-3.5-haiku-20240620',
|
||||||
|
'claude-3.7-sonnet-20240620',
|
||||||
|
'claude-3.7-haiku-20240620',
|
||||||
|
'anthropic/claude-3-opus-20240229',
|
||||||
|
'claude-3-opus-20240229/anthropic',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Claude 4 series models
|
||||||
|
const claude4Models = [
|
||||||
|
'claude-sonnet-4-20250514',
|
||||||
|
'claude-opus-4-20250514',
|
||||||
|
'claude-4-sonnet-20250514',
|
||||||
|
'claude-4-opus-20250514',
|
||||||
|
'anthropic/claude-sonnet-4-20250514',
|
||||||
|
'claude-sonnet-4-20250514/anthropic',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Test Claude 3 series
|
||||||
|
claude3Models.forEach((model) => {
|
||||||
|
client.setOptions({ modelOptions: { model } });
|
||||||
|
expect(
|
||||||
|
/claude-[3-9]/.test(client.modelOptions.model) ||
|
||||||
|
/claude-(?:sonnet|opus|haiku)-[4-9]/.test(client.modelOptions.model),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test Claude 4 series
|
||||||
|
claude4Models.forEach((model) => {
|
||||||
|
client.setOptions({ modelOptions: { model } });
|
||||||
|
expect(
|
||||||
|
/claude-[3-9]/.test(client.modelOptions.model) ||
|
||||||
|
/claude-(?:sonnet|opus|haiku)-[4-9]/.test(client.modelOptions.model),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test non-Claude 3/4 models
|
||||||
|
const nonClaudeModels = ['claude-2', 'claude-instant', 'gpt-4', 'gpt-3.5-turbo'];
|
||||||
|
|
||||||
|
nonClaudeModels.forEach((model) => {
|
||||||
|
client.setOptions({ modelOptions: { model } });
|
||||||
|
expect(
|
||||||
|
/claude-[3-9]/.test(client.modelOptions.model) ||
|
||||||
|
/claude-(?:sonnet|opus|haiku)-[4-9]/.test(client.modelOptions.model),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const { Constants } = require('librechat-data-provider');
|
const { Constants } = require('librechat-data-provider');
|
||||||
const { initializeFakeClient } = require('./FakeClient');
|
const { initializeFakeClient } = require('./FakeClient');
|
||||||
|
|
||||||
jest.mock('~/lib/db/connectDb');
|
jest.mock('~/db/connect');
|
||||||
jest.mock('~/models', () => ({
|
jest.mock('~/models', () => ({
|
||||||
User: jest.fn(),
|
User: jest.fn(),
|
||||||
Key: jest.fn(),
|
Key: jest.fn(),
|
||||||
@@ -33,7 +33,9 @@ jest.mock('~/models', () => ({
|
|||||||
const { getConvo, saveConvo } = require('~/models');
|
const { getConvo, saveConvo } = require('~/models');
|
||||||
|
|
||||||
jest.mock('@librechat/agents', () => {
|
jest.mock('@librechat/agents', () => {
|
||||||
|
const { Providers } = jest.requireActual('@librechat/agents');
|
||||||
return {
|
return {
|
||||||
|
Providers,
|
||||||
ChatOpenAI: jest.fn().mockImplementation(() => {
|
ChatOpenAI: jest.fn().mockImplementation(() => {
|
||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
@@ -52,7 +54,7 @@ const messageHistory = [
|
|||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
isCreatedByUser: true,
|
isCreatedByUser: true,
|
||||||
text: 'What\'s up',
|
text: "What's up",
|
||||||
messageId: '3',
|
messageId: '3',
|
||||||
parentMessageId: '2',
|
parentMessageId: '2',
|
||||||
},
|
},
|
||||||
@@ -456,7 +458,7 @@ describe('BaseClient', () => {
|
|||||||
|
|
||||||
const chatMessages2 = await TestClient.loadHistory(conversationId, '3');
|
const chatMessages2 = await TestClient.loadHistory(conversationId, '3');
|
||||||
expect(TestClient.currentMessages).toHaveLength(3);
|
expect(TestClient.currentMessages).toHaveLength(3);
|
||||||
expect(chatMessages2[chatMessages2.length - 1].text).toEqual('What\'s up');
|
expect(chatMessages2[chatMessages2.length - 1].text).toEqual("What's up");
|
||||||
});
|
});
|
||||||
|
|
||||||
/* Most of the new sendMessage logic revolving around edited/continued AI messages
|
/* Most of the new sendMessage logic revolving around edited/continued AI messages
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const getLogStores = require('~/cache/getLogStores');
|
|||||||
const OpenAIClient = require('../OpenAIClient');
|
const OpenAIClient = require('../OpenAIClient');
|
||||||
jest.mock('meilisearch');
|
jest.mock('meilisearch');
|
||||||
|
|
||||||
jest.mock('~/lib/db/connectDb');
|
jest.mock('~/db/connect');
|
||||||
jest.mock('~/models', () => ({
|
jest.mock('~/models', () => ({
|
||||||
User: jest.fn(),
|
User: jest.fn(),
|
||||||
Key: jest.fn(),
|
Key: jest.fn(),
|
||||||
@@ -462,17 +462,17 @@ describe('OpenAIClient', () => {
|
|||||||
role: 'system',
|
role: 'system',
|
||||||
name: 'example_user',
|
name: 'example_user',
|
||||||
content:
|
content:
|
||||||
'Let\'s circle back when we have more bandwidth to touch base on opportunities for increased leverage.',
|
"Let's circle back when we have more bandwidth to touch base on opportunities for increased leverage.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: 'system',
|
role: 'system',
|
||||||
name: 'example_assistant',
|
name: 'example_assistant',
|
||||||
content: 'Let\'s talk later when we\'re less busy about how to do better.',
|
content: "Let's talk later when we're less busy about how to do better.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content:
|
content:
|
||||||
'This late pivot means we don\'t have time to boil the ocean for the client deliverable.',
|
"This late pivot means we don't have time to boil the ocean for the client deliverable.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const { Constants } = require('librechat-data-provider');
|
|||||||
const { HumanMessage, AIMessage } = require('@langchain/core/messages');
|
const { HumanMessage, AIMessage } = require('@langchain/core/messages');
|
||||||
const PluginsClient = require('../PluginsClient');
|
const PluginsClient = require('../PluginsClient');
|
||||||
|
|
||||||
jest.mock('~/lib/db/connectDb');
|
jest.mock('~/db/connect');
|
||||||
jest.mock('~/models/Conversation', () => {
|
jest.mock('~/models/Conversation', () => {
|
||||||
return function () {
|
return function () {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
require('dotenv').config();
|
|
||||||
const fs = require('fs');
|
|
||||||
const { z } = require('zod');
|
|
||||||
const path = require('path');
|
|
||||||
const yaml = require('js-yaml');
|
|
||||||
const { createOpenAPIChain } = require('langchain/chains');
|
|
||||||
const { DynamicStructuredTool } = require('@langchain/core/tools');
|
|
||||||
const { ChatPromptTemplate, HumanMessagePromptTemplate } = require('@langchain/core/prompts');
|
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
function addLinePrefix(text, prefix = '// ') {
|
|
||||||
return text
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => prefix + line)
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPrompt(name, functions) {
|
|
||||||
const prefix = `// The ${name} tool has the following functions. Determine the desired or most optimal function for the user's query:`;
|
|
||||||
const functionDescriptions = functions
|
|
||||||
.map((func) => `// - ${func.name}: ${func.description}`)
|
|
||||||
.join('\n');
|
|
||||||
return `${prefix}\n${functionDescriptions}
|
|
||||||
// You are an expert manager and scrum master. You must provide a detailed intent to better execute the function.
|
|
||||||
// Always format as such: {{"func": "function_name", "intent": "intent and expected result"}}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthBearer = z
|
|
||||||
.object({
|
|
||||||
type: z.string().includes('service_http'),
|
|
||||||
authorization_type: z.string().includes('bearer'),
|
|
||||||
verification_tokens: z.object({
|
|
||||||
openai: z.string(),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
const AuthDefinition = z
|
|
||||||
.object({
|
|
||||||
type: z.string(),
|
|
||||||
authorization_type: z.string(),
|
|
||||||
verification_tokens: z.object({
|
|
||||||
openai: z.string(),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
async function readSpecFile(filePath) {
|
|
||||||
try {
|
|
||||||
const fileContents = await fs.promises.readFile(filePath, 'utf8');
|
|
||||||
if (path.extname(filePath) === '.json') {
|
|
||||||
return JSON.parse(fileContents);
|
|
||||||
}
|
|
||||||
return yaml.load(fileContents);
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('[readSpecFile] error', e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSpec(url) {
|
|
||||||
const RegularUrl = z
|
|
||||||
.string()
|
|
||||||
.url()
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
if (RegularUrl.parse(url) && path.extname(url) === '.json') {
|
|
||||||
const response = await fetch(url);
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
const ValidSpecPath = z
|
|
||||||
.string()
|
|
||||||
.url()
|
|
||||||
.catch(async () => {
|
|
||||||
const spec = path.join(__dirname, '..', '.well-known', 'openapi', url);
|
|
||||||
if (!fs.existsSync(spec)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await readSpecFile(spec);
|
|
||||||
});
|
|
||||||
|
|
||||||
return ValidSpecPath.parse(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createOpenAPIPlugin({ data, llm, user, message, memory, signal }) {
|
|
||||||
let spec;
|
|
||||||
try {
|
|
||||||
spec = await getSpec(data.api.url);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[createOpenAPIPlugin] getSpec error', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!spec) {
|
|
||||||
logger.warn('[createOpenAPIPlugin] No spec found');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = {};
|
|
||||||
const { auth, name_for_model, description_for_model, description_for_human } = data;
|
|
||||||
if (auth && AuthDefinition.parse(auth)) {
|
|
||||||
logger.debug('[createOpenAPIPlugin] auth detected', auth);
|
|
||||||
const { openai } = auth.verification_tokens;
|
|
||||||
if (AuthBearer.parse(auth)) {
|
|
||||||
headers.authorization = `Bearer ${openai}`;
|
|
||||||
logger.debug('[createOpenAPIPlugin] added auth bearer', headers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const chainOptions = { llm };
|
|
||||||
|
|
||||||
if (data.headers && data.headers['librechat_user_id']) {
|
|
||||||
logger.debug('[createOpenAPIPlugin] id detected', headers);
|
|
||||||
headers[data.headers['librechat_user_id']] = user;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(headers).length > 0) {
|
|
||||||
logger.debug('[createOpenAPIPlugin] headers detected', headers);
|
|
||||||
chainOptions.headers = headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.params) {
|
|
||||||
logger.debug('[createOpenAPIPlugin] params detected', data.params);
|
|
||||||
chainOptions.params = data.params;
|
|
||||||
}
|
|
||||||
|
|
||||||
let history = '';
|
|
||||||
if (memory) {
|
|
||||||
logger.debug('[createOpenAPIPlugin] openAPI chain: memory detected', memory);
|
|
||||||
const { history: chat_history } = await memory.loadMemoryVariables({});
|
|
||||||
history = chat_history?.length > 0 ? `\n\n## Chat History:\n${chat_history}\n` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
chainOptions.prompt = ChatPromptTemplate.fromMessages([
|
|
||||||
HumanMessagePromptTemplate.fromTemplate(
|
|
||||||
`# Use the provided API's to respond to this query:\n\n{query}\n\n## Instructions:\n${addLinePrefix(
|
|
||||||
description_for_model,
|
|
||||||
)}${history}`,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const chain = await createOpenAPIChain(spec, chainOptions);
|
|
||||||
|
|
||||||
const { functions } = chain.chains[0].lc_kwargs.llmKwargs;
|
|
||||||
|
|
||||||
return new DynamicStructuredTool({
|
|
||||||
name: name_for_model,
|
|
||||||
description_for_model: `${addLinePrefix(description_for_human)}${createPrompt(
|
|
||||||
name_for_model,
|
|
||||||
functions,
|
|
||||||
)}`,
|
|
||||||
description: `${description_for_human}`,
|
|
||||||
schema: z.object({
|
|
||||||
func: z
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
`The function to invoke. The functions available are: ${functions
|
|
||||||
.map((func) => func.name)
|
|
||||||
.join(', ')}`,
|
|
||||||
),
|
|
||||||
intent: z
|
|
||||||
.string()
|
|
||||||
.describe('Describe your intent with the function and your expected result'),
|
|
||||||
}),
|
|
||||||
func: async ({ func = '', intent = '' }) => {
|
|
||||||
const filteredFunctions = functions.filter((f) => f.name === func);
|
|
||||||
chain.chains[0].lc_kwargs.llmKwargs.functions = filteredFunctions;
|
|
||||||
const query = `${message}${func?.length > 0 ? `\n// Intent: ${intent}` : ''}`;
|
|
||||||
const result = await chain.call({
|
|
||||||
query,
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
return result.response;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getSpec,
|
|
||||||
readSpecFile,
|
|
||||||
createOpenAPIPlugin,
|
|
||||||
};
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const { createOpenAPIPlugin, getSpec, readSpecFile } = require('./OpenAPIPlugin');
|
|
||||||
|
|
||||||
global.fetch = jest.fn().mockImplementationOnce(() => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
resolve({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({ key: 'value' }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
jest.mock('fs', () => ({
|
|
||||||
promises: {
|
|
||||||
readFile: jest.fn(),
|
|
||||||
},
|
|
||||||
existsSync: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('readSpecFile', () => {
|
|
||||||
it('reads JSON file correctly', async () => {
|
|
||||||
fs.promises.readFile.mockResolvedValue(JSON.stringify({ test: 'value' }));
|
|
||||||
const result = await readSpecFile('test.json');
|
|
||||||
expect(result).toEqual({ test: 'value' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reads YAML file correctly', async () => {
|
|
||||||
fs.promises.readFile.mockResolvedValue('test: value');
|
|
||||||
const result = await readSpecFile('test.yaml');
|
|
||||||
expect(result).toEqual({ test: 'value' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles error correctly', async () => {
|
|
||||||
fs.promises.readFile.mockRejectedValue(new Error('test error'));
|
|
||||||
const result = await readSpecFile('test.json');
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getSpec', () => {
|
|
||||||
it('fetches spec from url correctly', async () => {
|
|
||||||
const parsedJson = await getSpec('https://www.instacart.com/.well-known/ai-plugin.json');
|
|
||||||
const isObject = typeof parsedJson === 'object';
|
|
||||||
expect(isObject).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reads spec from file correctly', async () => {
|
|
||||||
fs.existsSync.mockReturnValue(true);
|
|
||||||
fs.promises.readFile.mockResolvedValue(JSON.stringify({ test: 'value' }));
|
|
||||||
const result = await getSpec('test.json');
|
|
||||||
expect(result).toEqual({ test: 'value' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false when file does not exist', async () => {
|
|
||||||
fs.existsSync.mockReturnValue(false);
|
|
||||||
const result = await getSpec('test.json');
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('createOpenAPIPlugin', () => {
|
|
||||||
it('returns null when getSpec throws an error', async () => {
|
|
||||||
const result = await createOpenAPIPlugin({ data: { api: { url: 'invalid' } } });
|
|
||||||
expect(result).toBe(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when no spec is found', async () => {
|
|
||||||
const result = await createOpenAPIPlugin({});
|
|
||||||
expect(result).toBe(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add more tests here for different scenarios
|
|
||||||
});
|
|
||||||
@@ -8,10 +8,10 @@ const { HttpsProxyAgent } = require('https-proxy-agent');
|
|||||||
const { FileContext, ContentTypes } = require('librechat-data-provider');
|
const { FileContext, ContentTypes } = require('librechat-data-provider');
|
||||||
const { getImageBasename } = require('~/server/services/Files/images');
|
const { getImageBasename } = require('~/server/services/Files/images');
|
||||||
const extractBaseURL = require('~/utils/extractBaseURL');
|
const extractBaseURL = require('~/utils/extractBaseURL');
|
||||||
const { logger } = require('~/config');
|
const logger = require('~/config/winston');
|
||||||
|
|
||||||
const displayMessage =
|
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.';
|
"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 {
|
class DALLE3 extends Tool {
|
||||||
constructor(fields = {}) {
|
constructor(fields = {}) {
|
||||||
super();
|
super();
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ const { v4 } = require('uuid');
|
|||||||
const OpenAI = require('openai');
|
const OpenAI = require('openai');
|
||||||
const FormData = require('form-data');
|
const FormData = require('form-data');
|
||||||
const { tool } = require('@langchain/core/tools');
|
const { tool } = require('@langchain/core/tools');
|
||||||
|
const { logAxiosError } = require('@librechat/api');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||||
const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
|
const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
|
||||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||||
const { logAxiosError, extractBaseURL } = require('~/utils');
|
const { extractBaseURL } = require('~/utils');
|
||||||
const { getFiles } = require('~/models/File');
|
const { getFiles } = require('~/models/File');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
/** Default descriptions for image generation tool */
|
/** Default descriptions for image generation tool */
|
||||||
const DEFAULT_IMAGE_GEN_DESCRIPTION = `
|
const DEFAULT_IMAGE_GEN_DESCRIPTION = `
|
||||||
@@ -30,7 +31,7 @@ const DEFAULT_IMAGE_EDIT_DESCRIPTION =
|
|||||||
|
|
||||||
When to use \`image_edit_oai\`:
|
When to use \`image_edit_oai\`:
|
||||||
- The user wants to modify, extend, or remix one **or more** uploaded images, either:
|
- The user wants to modify, extend, or remix one **or more** uploaded images, either:
|
||||||
- Previously generated, or in the current request (both to be included in the \`image_ids\` array).
|
- Previously generated, or in the current request (both to be included in the \`image_ids\` array).
|
||||||
- Always when the user refers to uploaded images for editing, enhancement, remixing, style transfer, or combining elements.
|
- Always when the user refers to uploaded images for editing, enhancement, remixing, style transfer, or combining elements.
|
||||||
- Any current or existing images are to be used as visual guides.
|
- Any current or existing images are to be used as visual guides.
|
||||||
- If there are any files in the current request, they are more likely than not expected as references for image edit requests.
|
- If there are any files in the current request, they are more likely than not expected as references for image edit requests.
|
||||||
@@ -64,7 +65,7 @@ const DEFAULT_IMAGE_EDIT_PROMPT_DESCRIPTION = `Describe the changes, enhancement
|
|||||||
Always base this prompt on the most recently uploaded reference images.`;
|
Always base this prompt on the most recently uploaded reference images.`;
|
||||||
|
|
||||||
const displayMessage =
|
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.';
|
"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
|
* Replaces unwanted characters from the input string
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
const OpenAI = require('openai');
|
const OpenAI = require('openai');
|
||||||
const DALLE3 = require('../DALLE3');
|
const DALLE3 = require('../DALLE3');
|
||||||
|
const logger = require('~/config/winston');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
jest.mock('openai');
|
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 processFileURL = jest.fn();
|
||||||
|
|
||||||
jest.mock('~/server/services/Files/images', () => ({
|
jest.mock('~/server/services/Files/images', () => ({
|
||||||
@@ -37,6 +56,11 @@ jest.mock('fs', () => {
|
|||||||
return {
|
return {
|
||||||
existsSync: jest.fn(),
|
existsSync: jest.fn(),
|
||||||
mkdirSync: jest.fn(),
|
mkdirSync: jest.fn(),
|
||||||
|
promises: {
|
||||||
|
writeFile: jest.fn(),
|
||||||
|
readFile: jest.fn(),
|
||||||
|
unlink: jest.fn(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ const createFileSearchTool = async ({ req, files, entity_id }) => {
|
|||||||
query: z
|
query: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.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.',
|
"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.",
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
const { SerpAPI } = require('@langchain/community/tools/serpapi');
|
const { SerpAPI } = require('@langchain/community/tools/serpapi');
|
||||||
const { Calculator } = require('@langchain/community/tools/calculator');
|
const { Calculator } = require('@langchain/community/tools/calculator');
|
||||||
const { createCodeExecutionTool, EnvVar } = require('@librechat/agents');
|
const { EnvVar, createCodeExecutionTool, createSearchTool } = require('@librechat/agents');
|
||||||
const { Tools, Constants, EToolResources } = require('librechat-data-provider');
|
const {
|
||||||
|
Tools,
|
||||||
|
Constants,
|
||||||
|
EToolResources,
|
||||||
|
loadWebSearchAuth,
|
||||||
|
replaceSpecialVars,
|
||||||
|
} = require('librechat-data-provider');
|
||||||
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
||||||
const {
|
const {
|
||||||
availableTools,
|
availableTools,
|
||||||
@@ -138,7 +144,6 @@ const loadTools = async ({
|
|||||||
agent,
|
agent,
|
||||||
model,
|
model,
|
||||||
endpoint,
|
endpoint,
|
||||||
useSpecs,
|
|
||||||
tools = [],
|
tools = [],
|
||||||
options = {},
|
options = {},
|
||||||
functions = true,
|
functions = true,
|
||||||
@@ -263,6 +268,33 @@ const loadTools = async ({
|
|||||||
return createFileSearchTool({ req: options.req, files, entity_id: agent?.id });
|
return createFileSearchTool({ req: options.req, files, entity_id: agent?.id });
|
||||||
};
|
};
|
||||||
continue;
|
continue;
|
||||||
|
} else if (tool === Tools.web_search) {
|
||||||
|
const webSearchConfig = options?.req?.app?.locals?.webSearch;
|
||||||
|
const result = await loadWebSearchAuth({
|
||||||
|
userId: user,
|
||||||
|
loadAuthValues,
|
||||||
|
webSearchConfig,
|
||||||
|
});
|
||||||
|
const { onSearchResults, onGetHighlights } = options?.[Tools.web_search] ?? {};
|
||||||
|
requestedTools[tool] = async () => {
|
||||||
|
toolContextMap[tool] = `# \`${tool}\`:
|
||||||
|
Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
|
||||||
|
1. **Execute immediately without preface** when using \`${tool}\`.
|
||||||
|
2. **After the search, begin with a brief summary** that directly addresses the query without headers or explaining your process.
|
||||||
|
3. **Structure your response clearly** using Markdown formatting (Level 2 headers for sections, lists for multiple points, tables for comparisons).
|
||||||
|
4. **Cite sources properly** according to the citation anchor format, utilizing group anchors when appropriate.
|
||||||
|
5. **Tailor your approach to the query type** (academic, news, coding, etc.) while maintaining an expert, journalistic, unbiased tone.
|
||||||
|
6. **Provide comprehensive information** with specific details, examples, and as much relevant context as possible from search results.
|
||||||
|
7. **Avoid moralizing language.**
|
||||||
|
`.trim();
|
||||||
|
return createSearchTool({
|
||||||
|
...result.authResult,
|
||||||
|
onSearchResults,
|
||||||
|
onGetHighlights,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
continue;
|
||||||
} else if (tool && appTools[tool] && mcpToolPattern.test(tool)) {
|
} else if (tool && appTools[tool] && mcpToolPattern.test(tool)) {
|
||||||
requestedTools[tool] = async () =>
|
requestedTools[tool] = async () =>
|
||||||
createMCPTool({
|
createMCPTool({
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
const mockUser = {
|
const mongoose = require('mongoose');
|
||||||
_id: 'fakeId',
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
save: jest.fn(),
|
|
||||||
findByIdAndDelete: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockPluginService = {
|
const mockPluginService = {
|
||||||
updateUserPluginAuth: jest.fn(),
|
updateUserPluginAuth: jest.fn(),
|
||||||
@@ -10,23 +7,18 @@ const mockPluginService = {
|
|||||||
getUserPluginAuthValue: jest.fn(),
|
getUserPluginAuthValue: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.mock('~/models/User', () => {
|
|
||||||
return function () {
|
|
||||||
return mockUser;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('~/server/services/PluginService', () => mockPluginService);
|
jest.mock('~/server/services/PluginService', () => mockPluginService);
|
||||||
|
|
||||||
const { BaseLLM } = require('@langchain/openai');
|
const { BaseLLM } = require('@langchain/openai');
|
||||||
const { Calculator } = require('@langchain/community/tools/calculator');
|
const { Calculator } = require('@langchain/community/tools/calculator');
|
||||||
|
|
||||||
const User = require('~/models/User');
|
const { User } = require('~/db/models');
|
||||||
const PluginService = require('~/server/services/PluginService');
|
const PluginService = require('~/server/services/PluginService');
|
||||||
const { validateTools, loadTools, loadToolWithAuth } = require('./handleTools');
|
const { validateTools, loadTools, loadToolWithAuth } = require('./handleTools');
|
||||||
const { StructuredSD, availableTools, DALLE3 } = require('../');
|
const { StructuredSD, availableTools, DALLE3 } = require('../');
|
||||||
|
|
||||||
describe('Tool Handlers', () => {
|
describe('Tool Handlers', () => {
|
||||||
|
let mongoServer;
|
||||||
let fakeUser;
|
let fakeUser;
|
||||||
const pluginKey = 'dalle';
|
const pluginKey = 'dalle';
|
||||||
const pluginKey2 = 'wolfram';
|
const pluginKey2 = 'wolfram';
|
||||||
@@ -37,7 +29,9 @@ describe('Tool Handlers', () => {
|
|||||||
const authConfigs = mainPlugin.authConfig;
|
const authConfigs = mainPlugin.authConfig;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
mockUser.save.mockResolvedValue(undefined);
|
mongoServer = await MongoMemoryServer.create();
|
||||||
|
const mongoUri = mongoServer.getUri();
|
||||||
|
await mongoose.connect(mongoUri);
|
||||||
|
|
||||||
const userAuthValues = {};
|
const userAuthValues = {};
|
||||||
mockPluginService.getUserPluginAuthValue.mockImplementation((userId, authField) => {
|
mockPluginService.getUserPluginAuthValue.mockImplementation((userId, authField) => {
|
||||||
@@ -78,9 +72,36 @@ describe('Tool Handlers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await mockUser.findByIdAndDelete(fakeUser._id);
|
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) {
|
for (const authConfig of authConfigs) {
|
||||||
await PluginService.deleteUserPluginAuth(fakeUser._id, authConfig.authField);
|
await PluginService.updateUserPluginAuth(
|
||||||
|
fakeUser._id,
|
||||||
|
authConfig.authField,
|
||||||
|
pluginKey,
|
||||||
|
mockCredential,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -218,7 +239,6 @@ describe('Tool Handlers', () => {
|
|||||||
try {
|
try {
|
||||||
await loadTool2();
|
await loadTool2();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// eslint-disable-next-line jest/no-conditional-expect
|
|
||||||
expect(error).toBeDefined();
|
expect(error).toBeDefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
4
api/cache/banViolation.js
vendored
4
api/cache/banViolation.js
vendored
@@ -1,8 +1,8 @@
|
|||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { ViolationTypes } = require('librechat-data-provider');
|
const { ViolationTypes } = require('librechat-data-provider');
|
||||||
const { isEnabled, math, removePorts } = require('~/server/utils');
|
const { isEnabled, math, removePorts } = require('~/server/utils');
|
||||||
const { deleteAllUserSessions } = require('~/models');
|
const { deleteAllUserSessions } = require('~/models');
|
||||||
const getLogStores = require('./getLogStores');
|
const getLogStores = require('./getLogStores');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const { BAN_VIOLATIONS, BAN_INTERVAL } = process.env ?? {};
|
const { BAN_VIOLATIONS, BAN_INTERVAL } = process.env ?? {};
|
||||||
const interval = math(BAN_INTERVAL, 20);
|
const interval = math(BAN_INTERVAL, 20);
|
||||||
@@ -32,7 +32,6 @@ const banViolation = async (req, res, errorMessage) => {
|
|||||||
if (!isEnabled(BAN_VIOLATIONS)) {
|
if (!isEnabled(BAN_VIOLATIONS)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!errorMessage) {
|
if (!errorMessage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -51,7 +50,6 @@ const banViolation = async (req, res, errorMessage) => {
|
|||||||
|
|
||||||
const banLogs = getLogStores(ViolationTypes.BAN);
|
const banLogs = getLogStores(ViolationTypes.BAN);
|
||||||
const duration = errorMessage.duration || banLogs.opts.ttl;
|
const duration = errorMessage.duration || banLogs.opts.ttl;
|
||||||
|
|
||||||
if (duration <= 0) {
|
if (duration <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
60
api/cache/banViolation.spec.js
vendored
60
api/cache/banViolation.spec.js
vendored
@@ -1,48 +1,28 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
const banViolation = require('./banViolation');
|
const banViolation = require('./banViolation');
|
||||||
|
|
||||||
jest.mock('keyv');
|
// Mock deleteAllUserSessions since we're testing ban logic, not session deletion
|
||||||
jest.mock('../models/Session');
|
jest.mock('~/models', () => ({
|
||||||
// Mocking the getLogStores function
|
...jest.requireActual('~/models'),
|
||||||
jest.mock('./getLogStores', () => {
|
deleteAllUserSessions: jest.fn().mockResolvedValue(true),
|
||||||
return jest.fn().mockImplementation(() => {
|
}));
|
||||||
const EventEmitter = require('events');
|
|
||||||
const { CacheKeys } = require('librechat-data-provider');
|
|
||||||
const math = require('../server/utils/math');
|
|
||||||
const mockGet = jest.fn();
|
|
||||||
const mockSet = jest.fn();
|
|
||||||
class KeyvMongo extends EventEmitter {
|
|
||||||
constructor(url = 'mongodb://127.0.0.1:27017', options) {
|
|
||||||
super();
|
|
||||||
this.ttlSupport = false;
|
|
||||||
url = url ?? {};
|
|
||||||
if (typeof url === 'string') {
|
|
||||||
url = { url };
|
|
||||||
}
|
|
||||||
if (url.uri) {
|
|
||||||
url = { url: url.uri, ...url };
|
|
||||||
}
|
|
||||||
this.opts = {
|
|
||||||
url,
|
|
||||||
collection: 'keyv',
|
|
||||||
...url,
|
|
||||||
...options,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
get = mockGet;
|
|
||||||
set = mockSet;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new KeyvMongo('', {
|
|
||||||
namespace: CacheKeys.BANS,
|
|
||||||
ttl: math(process.env.BAN_DURATION, 7200000),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('banViolation', () => {
|
describe('banViolation', () => {
|
||||||
|
let mongoServer;
|
||||||
let req, res, errorMessage;
|
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(() => {
|
beforeEach(() => {
|
||||||
req = {
|
req = {
|
||||||
ip: '127.0.0.1',
|
ip: '127.0.0.1',
|
||||||
@@ -55,7 +35,7 @@ describe('banViolation', () => {
|
|||||||
};
|
};
|
||||||
errorMessage = {
|
errorMessage = {
|
||||||
type: 'someViolation',
|
type: 'someViolation',
|
||||||
user_id: '12345',
|
user_id: new mongoose.Types.ObjectId().toString(), // Use valid ObjectId
|
||||||
prev_count: 0,
|
prev_count: 0,
|
||||||
violation_count: 0,
|
violation_count: 0,
|
||||||
};
|
};
|
||||||
|
|||||||
7
api/cache/getLogStores.js
vendored
7
api/cache/getLogStores.js
vendored
@@ -1,7 +1,7 @@
|
|||||||
const { Keyv } = require('keyv');
|
const { Keyv } = require('keyv');
|
||||||
const { CacheKeys, ViolationTypes, Time } = require('librechat-data-provider');
|
const { CacheKeys, ViolationTypes, Time } = require('librechat-data-provider');
|
||||||
const { logFile, violationFile } = require('./keyvFiles');
|
const { logFile, violationFile } = require('./keyvFiles');
|
||||||
const { math, isEnabled } = require('~/server/utils');
|
const { isEnabled, math } = require('~/server/utils');
|
||||||
const keyvRedis = require('./keyvRedis');
|
const keyvRedis = require('./keyvRedis');
|
||||||
const keyvMongo = require('./keyvMongo');
|
const keyvMongo = require('./keyvMongo');
|
||||||
|
|
||||||
@@ -61,6 +61,10 @@ const abortKeys = isRedisEnabled
|
|||||||
? new Keyv({ store: keyvRedis })
|
? new Keyv({ store: keyvRedis })
|
||||||
: new Keyv({ namespace: CacheKeys.ABORT_KEYS, ttl: Time.TEN_MINUTES });
|
: new Keyv({ namespace: CacheKeys.ABORT_KEYS, ttl: Time.TEN_MINUTES });
|
||||||
|
|
||||||
|
const openIdExchangedTokensCache = isRedisEnabled
|
||||||
|
? new Keyv({ store: keyvRedis, ttl: Time.TEN_MINUTES })
|
||||||
|
: new Keyv({ namespace: CacheKeys.OPENID_EXCHANGED_TOKENS, ttl: Time.TEN_MINUTES });
|
||||||
|
|
||||||
const namespaces = {
|
const namespaces = {
|
||||||
[CacheKeys.ROLES]: roles,
|
[CacheKeys.ROLES]: roles,
|
||||||
[CacheKeys.CONFIG_STORE]: config,
|
[CacheKeys.CONFIG_STORE]: config,
|
||||||
@@ -98,6 +102,7 @@ const namespaces = {
|
|||||||
[CacheKeys.AUDIO_RUNS]: audioRuns,
|
[CacheKeys.AUDIO_RUNS]: audioRuns,
|
||||||
[CacheKeys.MESSAGES]: messages,
|
[CacheKeys.MESSAGES]: messages,
|
||||||
[CacheKeys.FLOWS]: flows,
|
[CacheKeys.FLOWS]: flows,
|
||||||
|
[CacheKeys.OPENID_EXCHANGED_TOKENS]: openIdExchangedTokensCache,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
11
api/cache/keyvRedis.js
vendored
11
api/cache/keyvRedis.js
vendored
@@ -76,10 +76,13 @@ if (REDIS_URI && isEnabled(USE_REDIS)) {
|
|||||||
keyvRedis = new KeyvRedis(REDIS_URI, keyvOpts);
|
keyvRedis = new KeyvRedis(REDIS_URI, keyvOpts);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pingInterval = setInterval(() => {
|
const pingInterval = setInterval(
|
||||||
logger.debug('KeyvRedis ping');
|
() => {
|
||||||
keyvRedis.client.ping().catch(err => logger.error('Redis keep-alive ping failed:', err));
|
logger.debug('KeyvRedis ping');
|
||||||
}, 5 * 60 * 1000);
|
keyvRedis.client.ping().catch((err) => logger.error('Redis keep-alive ping failed:', err));
|
||||||
|
},
|
||||||
|
5 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
keyvRedis.on('ready', () => {
|
keyvRedis.on('ready', () => {
|
||||||
logger.info('KeyvRedis connection ready');
|
logger.info('KeyvRedis connection ready');
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
const axios = require('axios');
|
|
||||||
const { EventSource } = require('eventsource');
|
const { EventSource } = require('eventsource');
|
||||||
const { Time, CacheKeys } = require('librechat-data-provider');
|
const { Time } = require('librechat-data-provider');
|
||||||
const { MCPManager, FlowStateManager } = require('librechat-mcp');
|
const { MCPManager, FlowStateManager } = require('@librechat/api');
|
||||||
const logger = require('./winston');
|
const logger = require('./winston');
|
||||||
|
|
||||||
global.EventSource = EventSource;
|
global.EventSource = EventSource;
|
||||||
@@ -37,60 +36,8 @@ function getFlowStateManager(flowsCache) {
|
|||||||
return flowManager;
|
return flowManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends message data in Server Sent Events format.
|
|
||||||
* @param {ServerResponse} res - The server response.
|
|
||||||
* @param {{ data: string | Record<string, unknown>, event?: string }} event - The message event.
|
|
||||||
* @param {string} event.event - The type of event.
|
|
||||||
* @param {string} event.data - The message to be sent.
|
|
||||||
*/
|
|
||||||
const sendEvent = (res, event) => {
|
|
||||||
if (typeof event.data === 'string' && event.data.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.write(`event: message\ndata: ${JSON.stringify(event)}\n\n`);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates and configures an Axios instance with optional proxy settings.
|
|
||||||
*
|
|
||||||
* @typedef {import('axios').AxiosInstance} AxiosInstance
|
|
||||||
* @typedef {import('axios').AxiosProxyConfig} AxiosProxyConfig
|
|
||||||
*
|
|
||||||
* @returns {AxiosInstance} A configured Axios instance
|
|
||||||
* @throws {Error} If there's an issue creating the Axios instance or parsing the proxy URL
|
|
||||||
*/
|
|
||||||
function createAxiosInstance() {
|
|
||||||
const instance = axios.create();
|
|
||||||
|
|
||||||
if (process.env.proxy) {
|
|
||||||
try {
|
|
||||||
const url = new URL(process.env.proxy);
|
|
||||||
|
|
||||||
/** @type {AxiosProxyConfig} */
|
|
||||||
const proxyConfig = {
|
|
||||||
host: url.hostname.replace(/^\[|\]$/g, ''),
|
|
||||||
protocol: url.protocol.replace(':', ''),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (url.port) {
|
|
||||||
proxyConfig.port = parseInt(url.port, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
instance.defaults.proxy = proxyConfig;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing proxy URL:', error);
|
|
||||||
throw new Error(`Invalid proxy URL: ${process.env.proxy}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
logger,
|
logger,
|
||||||
sendEvent,
|
|
||||||
getMCPManager,
|
getMCPManager,
|
||||||
createAxiosInstance,
|
|
||||||
getFlowStateManager,
|
getFlowStateManager,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ async function connectDb() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
cached.conn = await cached.promise;
|
cached.conn = await cached.promise;
|
||||||
|
|
||||||
return cached.conn;
|
return cached.conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = connectDb;
|
module.exports = {
|
||||||
|
connectDb,
|
||||||
|
};
|
||||||
8
api/db/index.js
Normal file
8
api/db/index.js
Normal file
@@ -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 };
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
const { MeiliSearch } = require('meilisearch');
|
const { MeiliSearch } = require('meilisearch');
|
||||||
const { Conversation } = require('~/models/Conversation');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { Message } = require('~/models/Message');
|
|
||||||
const { isEnabled } = require('~/server/utils');
|
const { isEnabled } = require('~/server/utils');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
const Conversation = mongoose.models.Conversation;
|
||||||
|
const Message = mongoose.models.Message;
|
||||||
|
|
||||||
const searchEnabled = isEnabled(process.env.SEARCH);
|
const searchEnabled = isEnabled(process.env.SEARCH);
|
||||||
const indexingDisabled = isEnabled(process.env.MEILI_NO_SYNC);
|
const indexingDisabled = isEnabled(process.env.MEILI_NO_SYNC);
|
||||||
@@ -29,7 +32,6 @@ async function indexSync() {
|
|||||||
if (!searchEnabled) {
|
if (!searchEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = MeiliSearchClient.getInstance();
|
const client = MeiliSearchClient.getInstance();
|
||||||
|
|
||||||
5
api/db/models.js
Normal file
5
api/db/models.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
const { createModels } = require('@librechat/data-schemas');
|
||||||
|
const models = createModels(mongoose);
|
||||||
|
|
||||||
|
module.exports = { ...models };
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
/** @type {import('jest').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
displayName: 'default',
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
clearMocks: true,
|
clearMocks: true,
|
||||||
roots: ['<rootDir>'],
|
roots: ['<rootDir>'],
|
||||||
@@ -11,5 +13,8 @@ module.exports = {
|
|||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'~/(.*)': '<rootDir>/$1',
|
'~/(.*)': '<rootDir>/$1',
|
||||||
'~/data/auth.json': '<rootDir>/__mocks__/auth.mock.json',
|
'~/data/auth.json': '<rootDir>/__mocks__/auth.mock.json',
|
||||||
|
'^openid-client/passport$': '<rootDir>/test/__mocks__/openid-client-passport.js',
|
||||||
|
'^openid-client$': '<rootDir>/test/__mocks__/openid-client.js',
|
||||||
},
|
},
|
||||||
|
transformIgnorePatterns: ['/node_modules/(?!(openid-client|oauth4webapi|jose)/).*/'],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
const connectDb = require('./connectDb');
|
|
||||||
const indexSync = require('./indexSync');
|
|
||||||
|
|
||||||
module.exports = { connectDb, indexSync };
|
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
const mongoose = require('mongoose');
|
const { Action } = require('~/db/models');
|
||||||
const { actionSchema } = require('@librechat/data-schemas');
|
|
||||||
|
|
||||||
const Action = mongoose.model('action', actionSchema);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an action with new data without overwriting existing properties,
|
* Update an action with new data without overwriting existing properties,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const { agentSchema } = require('@librechat/data-schemas');
|
const crypto = require('node:crypto');
|
||||||
const { SystemRoles, Tools } = require('librechat-data-provider');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const { SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider');
|
||||||
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
|
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
|
||||||
require('librechat-data-provider').Constants;
|
require('librechat-data-provider').Constants;
|
||||||
const { CONFIG_STORE, STARTUP_CONFIG } = require('librechat-data-provider').CacheKeys;
|
const { CONFIG_STORE, STARTUP_CONFIG } = require('librechat-data-provider').CacheKeys;
|
||||||
@@ -11,8 +12,8 @@ const {
|
|||||||
removeAgentFromAllProjects,
|
removeAgentFromAllProjects,
|
||||||
} = require('./Project');
|
} = require('./Project');
|
||||||
const getLogStores = require('~/cache/getLogStores');
|
const getLogStores = require('~/cache/getLogStores');
|
||||||
|
const { getActions } = require('./Action');
|
||||||
const Agent = mongoose.model('agent', agentSchema);
|
const { Agent } = require('~/db/models');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an agent with the provided data.
|
* Create an agent with the provided data.
|
||||||
@@ -21,7 +22,19 @@ const Agent = mongoose.model('agent', agentSchema);
|
|||||||
* @throws {Error} If the agent creation fails.
|
* @throws {Error} If the agent creation fails.
|
||||||
*/
|
*/
|
||||||
const createAgent = async (agentData) => {
|
const createAgent = async (agentData) => {
|
||||||
return (await Agent.create(agentData)).toObject();
|
const { author, ...versionData } = agentData;
|
||||||
|
const timestamp = new Date();
|
||||||
|
const initialAgentData = {
|
||||||
|
...agentData,
|
||||||
|
versions: [
|
||||||
|
{
|
||||||
|
...versionData,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return (await Agent.create(initialAgentData)).toObject();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,12 +61,17 @@ const loadEphemeralAgent = ({ req, agent_id, endpoint, model_parameters: _m }) =
|
|||||||
const { model, ...model_parameters } = _m;
|
const { model, ...model_parameters } = _m;
|
||||||
/** @type {Record<string, FunctionTool>} */
|
/** @type {Record<string, FunctionTool>} */
|
||||||
const availableTools = req.app.locals.availableTools;
|
const availableTools = req.app.locals.availableTools;
|
||||||
const mcpServers = new Set(req.body.ephemeralAgent?.mcp);
|
/** @type {TEphemeralAgent | null} */
|
||||||
|
const ephemeralAgent = req.body.ephemeralAgent;
|
||||||
|
const mcpServers = new Set(ephemeralAgent?.mcp);
|
||||||
/** @type {string[]} */
|
/** @type {string[]} */
|
||||||
const tools = [];
|
const tools = [];
|
||||||
if (req.body.ephemeralAgent?.execute_code === true) {
|
if (ephemeralAgent?.execute_code === true) {
|
||||||
tools.push(Tools.execute_code);
|
tools.push(Tools.execute_code);
|
||||||
}
|
}
|
||||||
|
if (ephemeralAgent?.web_search === true) {
|
||||||
|
tools.push(Tools.web_search);
|
||||||
|
}
|
||||||
|
|
||||||
if (mcpServers.size > 0) {
|
if (mcpServers.size > 0) {
|
||||||
for (const toolName of Object.keys(availableTools)) {
|
for (const toolName of Object.keys(availableTools)) {
|
||||||
@@ -103,6 +121,8 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
agent.version = agent.versions ? agent.versions.length : 0;
|
||||||
|
|
||||||
if (agent.author.toString() === req.user.id) {
|
if (agent.author.toString() === req.user.id) {
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
@@ -127,19 +147,205 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, $pull, $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) {
|
||||||
|
if (!wouldBeVersion[field] && !lastVersion[field]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(wouldBeVersion[field]) && Array.isArray(lastVersion[field])) {
|
||||||
|
if (wouldBeVersion[field].length !== lastVersion[field].length) {
|
||||||
|
isMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for projectIds (MongoDB ObjectIds)
|
||||||
|
if (field === 'projectIds') {
|
||||||
|
const wouldBeIds = wouldBeVersion[field].map((id) => id.toString()).sort();
|
||||||
|
const versionIds = lastVersion[field].map((id) => id.toString()).sort();
|
||||||
|
|
||||||
|
if (!wouldBeIds.every((id, i) => id === versionIds[i])) {
|
||||||
|
isMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle arrays of objects like tool_kwargs
|
||||||
|
else if (typeof wouldBeVersion[field][0] === 'object' && wouldBeVersion[field][0] !== null) {
|
||||||
|
const sortedWouldBe = [...wouldBeVersion[field]].map((item) => JSON.stringify(item)).sort();
|
||||||
|
const sortedVersion = [...lastVersion[field]].map((item) => JSON.stringify(item)).sort();
|
||||||
|
|
||||||
|
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
|
||||||
|
isMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const sortedWouldBe = [...wouldBeVersion[field]].sort();
|
||||||
|
const sortedVersion = [...lastVersion[field]].sort();
|
||||||
|
|
||||||
|
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
|
||||||
|
isMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (field === 'model_parameters') {
|
||||||
|
const wouldBeParams = wouldBeVersion[field] || {};
|
||||||
|
const lastVersionParams = lastVersion[field] || {};
|
||||||
|
if (JSON.stringify(wouldBeParams) !== JSON.stringify(lastVersionParams)) {
|
||||||
|
isMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (wouldBeVersion[field] !== lastVersion[field]) {
|
||||||
|
isMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isMatch ? lastVersion : null;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an agent with new data without overwriting existing
|
* Update an agent with new data without overwriting existing
|
||||||
* properties, or create a new agent if it doesn't exist.
|
* 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 {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.id - The ID of the agent to update.
|
||||||
* @param {string} [searchParameter.author] - The user ID of the agent's author.
|
* @param {string} [searchParameter.author] - The user ID of the agent's author.
|
||||||
* @param {Object} updateData - An object containing the properties to update.
|
* @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<Agent>} The updated or newly created agent document as a plain object.
|
* @returns {Promise<Agent>} 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) => {
|
const updateAgent = async (searchParameter, updateData, options = {}) => {
|
||||||
const options = { new: true, upsert: false };
|
const { updatingUserId = null, forceVersion = false, skipVersioning = false } = options;
|
||||||
return Agent.findOneAndUpdate(searchParameter, updateData, options).lean();
|
const mongoOptions = { new: true, upsert: false };
|
||||||
|
|
||||||
|
const currentAgent = await Agent.findOne(searchParameter);
|
||||||
|
if (currentAgent) {
|
||||||
|
const { __v, _id, id, versions, author, ...versionData } = currentAgent.toObject();
|
||||||
|
const { $push, $pull, $addToSet, ...directUpdates } = 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) {
|
||||||
|
const error = new Error(
|
||||||
|
'Duplicate version: This would create a version identical to an existing one',
|
||||||
|
);
|
||||||
|
error.statusCode = 409;
|
||||||
|
error.details = {
|
||||||
|
duplicateVersion,
|
||||||
|
versionIndex: versions.findIndex(
|
||||||
|
(v) => JSON.stringify(duplicateVersion) === JSON.stringify(v),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -151,7 +357,7 @@ const updateAgent = async (searchParameter, updateData) => {
|
|||||||
* @param {string} params.file_id
|
* @param {string} params.file_id
|
||||||
* @returns {Promise<Agent>} The updated agent.
|
* @returns {Promise<Agent>} The updated agent.
|
||||||
*/
|
*/
|
||||||
const addAgentResourceFile = async ({ agent_id, tool_resource, file_id }) => {
|
const addAgentResourceFile = async ({ req, agent_id, tool_resource, file_id }) => {
|
||||||
const searchParameter = { id: agent_id };
|
const searchParameter = { id: agent_id };
|
||||||
let agent = await getAgent(searchParameter);
|
let agent = await getAgent(searchParameter);
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
@@ -177,7 +383,9 @@ const addAgentResourceFile = async ({ agent_id, tool_resource, file_id }) => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedAgent = await updateAgent(searchParameter, updateData);
|
const updatedAgent = await updateAgent(searchParameter, updateData, {
|
||||||
|
updatingUserId: req?.user?.id,
|
||||||
|
});
|
||||||
if (updatedAgent) {
|
if (updatedAgent) {
|
||||||
return updatedAgent;
|
return updatedAgent;
|
||||||
} else {
|
} else {
|
||||||
@@ -269,7 +477,6 @@ const getListAgents = async (searchParameter) => {
|
|||||||
delete globalQuery.author;
|
delete globalQuery.author;
|
||||||
query = { $or: [globalQuery, query] };
|
query = { $or: [globalQuery, query] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const agents = (
|
const agents = (
|
||||||
await Agent.find(query, {
|
await Agent.find(query, {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -341,7 +548,10 @@ const updateAgentProjects = async ({ user, agentId, projectIds, removeProjectIds
|
|||||||
delete updateQuery.author;
|
delete updateQuery.author;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedAgent = await updateAgent(updateQuery, updateOps);
|
const updatedAgent = await updateAgent(updateQuery, updateOps, {
|
||||||
|
updatingUserId: user.id,
|
||||||
|
skipVersioning: true,
|
||||||
|
});
|
||||||
if (updatedAgent) {
|
if (updatedAgent) {
|
||||||
return updatedAgent;
|
return updatedAgent;
|
||||||
}
|
}
|
||||||
@@ -358,15 +568,107 @@ const updateAgentProjects = async ({ user, agentId, projectIds, removeProjectIds
|
|||||||
return await getAgent({ id: 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<MongoAgent>} 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<string>} - 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a default agent based on the endpoint
|
||||||
|
* @param {string} endpoint
|
||||||
|
* @returns {Agent | null}
|
||||||
|
*/
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
Agent,
|
|
||||||
getAgent,
|
getAgent,
|
||||||
loadAgent,
|
loadAgent,
|
||||||
createAgent,
|
createAgent,
|
||||||
updateAgent,
|
updateAgent,
|
||||||
deleteAgent,
|
deleteAgent,
|
||||||
getListAgents,
|
getListAgents,
|
||||||
|
revertAgentVersion,
|
||||||
updateAgentProjects,
|
updateAgentProjects,
|
||||||
addAgentResourceFile,
|
addAgentResourceFile,
|
||||||
removeAgentResourceFiles,
|
removeAgentResourceFiles,
|
||||||
|
generateActionMetadataHash,
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,4 @@
|
|||||||
const mongoose = require('mongoose');
|
const { Assistant } = require('~/db/models');
|
||||||
const { assistantSchema } = require('@librechat/data-schemas');
|
|
||||||
|
|
||||||
const Assistant = mongoose.model('assistant', assistantSchema);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an assistant with new data without overwriting existing properties,
|
* Update an assistant with new data without overwriting existing properties,
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
const mongoose = require('mongoose');
|
|
||||||
const { balanceSchema } = require('@librechat/data-schemas');
|
|
||||||
|
|
||||||
module.exports = mongoose.model('Balance', balanceSchema);
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
const mongoose = require('mongoose');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const logger = require('~/config/winston');
|
const { Banner } = require('~/db/models');
|
||||||
const { bannerSchema } = require('@librechat/data-schemas');
|
|
||||||
|
|
||||||
const Banner = mongoose.model('Banner', bannerSchema);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the current active banner.
|
* Retrieves the current active banner.
|
||||||
@@ -28,4 +25,4 @@ const getBanner = async (user) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { Banner, getBanner };
|
module.exports = { getBanner };
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
const mongoose = require('mongoose');
|
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const major = [0, 0];
|
|
||||||
const minor = [0, 0];
|
|
||||||
const patch = [0, 5];
|
|
||||||
|
|
||||||
const configSchema = mongoose.Schema(
|
|
||||||
{
|
|
||||||
tag: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
validate: {
|
|
||||||
validator: function (tag) {
|
|
||||||
const [part1, part2, part3] = tag.replace('v', '').split('.').map(Number);
|
|
||||||
|
|
||||||
// Check if all parts are numbers
|
|
||||||
if (isNaN(part1) || isNaN(part2) || isNaN(part3)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if all parts are within their respective ranges
|
|
||||||
if (part1 < major[0] || part1 > major[1]) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (part2 < minor[0] || part2 > minor[1]) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (part3 < patch[0] || part3 > patch[1]) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
message: 'Invalid tag value',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
searchEnabled: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
usersEnabled: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
startupCounts: {
|
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timestamps: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Instance method
|
|
||||||
configSchema.methods.incrementCount = function () {
|
|
||||||
this.startupCounts += 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Static methods
|
|
||||||
configSchema.statics.findByTag = async function (tag) {
|
|
||||||
return await this.findOne({ tag }).lean();
|
|
||||||
};
|
|
||||||
|
|
||||||
configSchema.statics.updateByTag = async function (tag, update) {
|
|
||||||
return await this.findOneAndUpdate({ tag }, update, { new: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const Config = mongoose.models.Config || mongoose.model('Config', configSchema);
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getConfigs: async (filter) => {
|
|
||||||
try {
|
|
||||||
return await Config.find(filter).lean();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error getting configs', error);
|
|
||||||
return { config: 'Error getting configs' };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
deleteConfigs: async (filter) => {
|
|
||||||
try {
|
|
||||||
return await Config.deleteMany(filter);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error deleting configs', error);
|
|
||||||
return { config: 'Error deleting configs' };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
const Conversation = require('./schema/convoSchema');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { getMessages, deleteMessages } = require('./Message');
|
const { getMessages, deleteMessages } = require('./Message');
|
||||||
const logger = require('~/config/winston');
|
const { Conversation } = require('~/db/models');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Searches for a conversation by conversationId and returns a lean document with only conversationId and user.
|
* Searches for a conversation by conversationId and returns a lean document with only conversationId and user.
|
||||||
@@ -75,7 +75,6 @@ const getConvoFiles = async (conversationId) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
Conversation,
|
|
||||||
getConvoFiles,
|
getConvoFiles,
|
||||||
searchConversation,
|
searchConversation,
|
||||||
deleteNullOrEmptyConversations,
|
deleteNullOrEmptyConversations,
|
||||||
@@ -155,7 +154,6 @@ module.exports = {
|
|||||||
{ cursor, limit = 25, isArchived = false, tags, search, order = 'desc' } = {},
|
{ cursor, limit = 25, isArchived = false, tags, search, order = 'desc' } = {},
|
||||||
) => {
|
) => {
|
||||||
const filters = [{ user }];
|
const filters = [{ user }];
|
||||||
|
|
||||||
if (isArchived) {
|
if (isArchived) {
|
||||||
filters.push({ isArchived: true });
|
filters.push({ isArchived: true });
|
||||||
} else {
|
} else {
|
||||||
@@ -288,7 +286,6 @@ module.exports = {
|
|||||||
deleteConvos: async (user, filter) => {
|
deleteConvos: async (user, filter) => {
|
||||||
try {
|
try {
|
||||||
const userFilter = { ...filter, user };
|
const userFilter = { ...filter, user };
|
||||||
|
|
||||||
const conversations = await Conversation.find(userFilter).select('conversationId');
|
const conversations = await Conversation.find(userFilter).select('conversationId');
|
||||||
const conversationIds = conversations.map((c) => c.conversationId);
|
const conversationIds = conversations.map((c) => c.conversationId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
const mongoose = require('mongoose');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const Conversation = require('./schema/convoSchema');
|
const { ConversationTag, Conversation } = require('~/db/models');
|
||||||
const logger = require('~/config/winston');
|
|
||||||
|
|
||||||
const { conversationTagSchema } = require('@librechat/data-schemas');
|
|
||||||
|
|
||||||
const ConversationTag = mongoose.model('ConversationTag', conversationTagSchema);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves all conversation tags for a user.
|
* Retrieves all conversation tags for a user.
|
||||||
@@ -140,13 +135,13 @@ const adjustPositions = async (user, oldPosition, newPosition) => {
|
|||||||
const position =
|
const position =
|
||||||
oldPosition < newPosition
|
oldPosition < newPosition
|
||||||
? {
|
? {
|
||||||
$gt: Math.min(oldPosition, newPosition),
|
$gt: Math.min(oldPosition, newPosition),
|
||||||
$lte: Math.max(oldPosition, newPosition),
|
$lte: Math.max(oldPosition, newPosition),
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
$gte: Math.min(oldPosition, newPosition),
|
$gte: Math.min(oldPosition, newPosition),
|
||||||
$lt: Math.max(oldPosition, newPosition),
|
$lt: Math.max(oldPosition, newPosition),
|
||||||
};
|
};
|
||||||
|
|
||||||
await ConversationTag.updateMany(
|
await ConversationTag.updateMany(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
const mongoose = require('mongoose');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { EToolResources } = require('librechat-data-provider');
|
const { EToolResources } = require('librechat-data-provider');
|
||||||
const { fileSchema } = require('@librechat/data-schemas');
|
const { File } = require('~/db/models');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const File = mongoose.model('File', fileSchema);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds a file by its file_id with additional query options.
|
* Finds a file by its file_id with additional query options.
|
||||||
@@ -169,7 +166,6 @@ async function batchUpdateFiles(updates) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
File,
|
|
||||||
findFileById,
|
findFileById,
|
||||||
getFiles,
|
getFiles,
|
||||||
getToolFilesByIds,
|
getToolFilesByIds,
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
const mongoose = require('mongoose');
|
|
||||||
const { keySchema } = require('@librechat/data-schemas');
|
|
||||||
|
|
||||||
module.exports = mongoose.model('Key', keySchema);
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
const { z } = require('zod');
|
const { z } = require('zod');
|
||||||
const Message = require('./schema/messageSchema');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { logger } = require('~/config');
|
const { Message } = require('~/db/models');
|
||||||
|
|
||||||
const idSchema = z.string().uuid();
|
const idSchema = z.string().uuid();
|
||||||
|
|
||||||
@@ -68,7 +68,6 @@ async function saveMessage(req, params, metadata) {
|
|||||||
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
|
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
|
||||||
update.tokenCount = 0;
|
update.tokenCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = await Message.findOneAndUpdate(
|
const message = await Message.findOneAndUpdate(
|
||||||
{ messageId: params.messageId, user: req.user.id },
|
{ messageId: params.messageId, user: req.user.id },
|
||||||
update,
|
update,
|
||||||
@@ -140,7 +139,6 @@ async function bulkSaveMessages(messages, overrideTimestamp = false) {
|
|||||||
upsert: true,
|
upsert: true,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const result = await Message.bulkWrite(bulkOps);
|
const result = await Message.bulkWrite(bulkOps);
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -255,6 +253,7 @@ async function updateMessage(req, message, metadata) {
|
|||||||
text: updatedMessage.text,
|
text: updatedMessage.text,
|
||||||
isCreatedByUser: updatedMessage.isCreatedByUser,
|
isCreatedByUser: updatedMessage.isCreatedByUser,
|
||||||
tokenCount: updatedMessage.tokenCount,
|
tokenCount: updatedMessage.tokenCount,
|
||||||
|
feedback: updatedMessage.feedback,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error updating message:', err);
|
logger.error('Error updating message:', err);
|
||||||
@@ -355,7 +354,6 @@ async function deleteMessages(filter) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
Message,
|
|
||||||
saveMessage,
|
saveMessage,
|
||||||
bulkSaveMessages,
|
bulkSaveMessages,
|
||||||
recordMessage,
|
recordMessage,
|
||||||
|
|||||||
@@ -1,32 +1,7 @@
|
|||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const { messageSchema } = require('@librechat/data-schemas');
|
||||||
jest.mock('mongoose');
|
|
||||||
|
|
||||||
const mockFindQuery = {
|
|
||||||
select: jest.fn().mockReturnThis(),
|
|
||||||
sort: jest.fn().mockReturnThis(),
|
|
||||||
lean: jest.fn().mockReturnThis(),
|
|
||||||
deleteMany: jest.fn().mockResolvedValue({ deletedCount: 1 }),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockSchema = {
|
|
||||||
findOneAndUpdate: jest.fn(),
|
|
||||||
updateOne: jest.fn(),
|
|
||||||
findOne: jest.fn(() => ({
|
|
||||||
lean: jest.fn(),
|
|
||||||
})),
|
|
||||||
find: jest.fn(() => mockFindQuery),
|
|
||||||
deleteMany: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
mongoose.model.mockReturnValue(mockSchema);
|
|
||||||
|
|
||||||
jest.mock('~/models/schema/messageSchema', () => mockSchema);
|
|
||||||
|
|
||||||
jest.mock('~/config/winston', () => ({
|
|
||||||
error: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
saveMessage,
|
saveMessage,
|
||||||
@@ -35,77 +10,102 @@ const {
|
|||||||
deleteMessages,
|
deleteMessages,
|
||||||
updateMessageText,
|
updateMessageText,
|
||||||
deleteMessagesSince,
|
deleteMessagesSince,
|
||||||
} = require('~/models/Message');
|
} = require('./Message');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {import('mongoose').Model<import('@librechat/data-schemas').IMessage>}
|
||||||
|
*/
|
||||||
|
let Message;
|
||||||
|
|
||||||
describe('Message Operations', () => {
|
describe('Message Operations', () => {
|
||||||
|
let mongoServer;
|
||||||
let mockReq;
|
let mockReq;
|
||||||
let mockMessage;
|
let mockMessageData;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeAll(async () => {
|
||||||
jest.clearAllMocks();
|
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 = {
|
mockReq = {
|
||||||
user: { id: 'user123' },
|
user: { id: 'user123' },
|
||||||
};
|
};
|
||||||
|
|
||||||
mockMessage = {
|
mockMessageData = {
|
||||||
messageId: 'msg123',
|
messageId: 'msg123',
|
||||||
conversationId: uuidv4(),
|
conversationId: uuidv4(),
|
||||||
text: 'Hello, world!',
|
text: 'Hello, world!',
|
||||||
user: 'user123',
|
user: 'user123',
|
||||||
};
|
};
|
||||||
|
|
||||||
mockSchema.findOneAndUpdate.mockResolvedValue({
|
|
||||||
toObject: () => mockMessage,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('saveMessage', () => {
|
describe('saveMessage', () => {
|
||||||
it('should save a message for an authenticated user', async () => {
|
it('should save a message for an authenticated user', async () => {
|
||||||
const result = await saveMessage(mockReq, mockMessage);
|
const result = await saveMessage(mockReq, mockMessageData);
|
||||||
expect(result).toEqual(mockMessage);
|
|
||||||
expect(mockSchema.findOneAndUpdate).toHaveBeenCalledWith(
|
expect(result.messageId).toBe('msg123');
|
||||||
{ messageId: 'msg123', user: 'user123' },
|
expect(result.user).toBe('user123');
|
||||||
expect.objectContaining({ user: 'user123' }),
|
expect(result.text).toBe('Hello, world!');
|
||||||
expect.any(Object),
|
|
||||||
);
|
// 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 () => {
|
it('should throw an error for unauthenticated user', async () => {
|
||||||
mockReq.user = null;
|
mockReq.user = null;
|
||||||
await expect(saveMessage(mockReq, mockMessage)).rejects.toThrow('User not authenticated');
|
await expect(saveMessage(mockReq, mockMessageData)).rejects.toThrow('User not authenticated');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error for invalid conversation ID', async () => {
|
it('should handle invalid conversation ID gracefully', async () => {
|
||||||
mockMessage.conversationId = 'invalid-id';
|
mockMessageData.conversationId = 'invalid-id';
|
||||||
await expect(saveMessage(mockReq, mockMessage)).resolves.toBeUndefined();
|
const result = await saveMessage(mockReq, mockMessageData);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateMessageText', () => {
|
describe('updateMessageText', () => {
|
||||||
it('should update message text for the authenticated user', async () => {
|
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' });
|
await updateMessageText(mockReq, { messageId: 'msg123', text: 'Updated text' });
|
||||||
expect(mockSchema.updateOne).toHaveBeenCalledWith(
|
|
||||||
{ messageId: 'msg123', user: 'user123' },
|
// Verify the update
|
||||||
{ text: 'Updated text' },
|
const updatedMessage = await Message.findOne({ messageId: 'msg123', user: 'user123' });
|
||||||
);
|
expect(updatedMessage.text).toBe('Updated text');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateMessage', () => {
|
describe('updateMessage', () => {
|
||||||
it('should update a message for the authenticated user', async () => {
|
it('should update a message for the authenticated user', async () => {
|
||||||
mockSchema.findOneAndUpdate.mockResolvedValue(mockMessage);
|
// First save a message
|
||||||
|
await saveMessage(mockReq, mockMessageData);
|
||||||
|
|
||||||
const result = await updateMessage(mockReq, { messageId: 'msg123', text: 'Updated text' });
|
const result = await updateMessage(mockReq, { messageId: 'msg123', text: 'Updated text' });
|
||||||
expect(result).toEqual(
|
|
||||||
expect.objectContaining({
|
expect(result.messageId).toBe('msg123');
|
||||||
messageId: 'msg123',
|
expect(result.text).toBe('Updated text');
|
||||||
text: 'Hello, world!',
|
|
||||||
}),
|
// 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 () => {
|
it('should throw an error if message is not found', async () => {
|
||||||
mockSchema.findOneAndUpdate.mockResolvedValue(null);
|
|
||||||
await expect(
|
await expect(
|
||||||
updateMessage(mockReq, { messageId: 'nonexistent', text: 'Test' }),
|
updateMessage(mockReq, { messageId: 'nonexistent', text: 'Test' }),
|
||||||
).rejects.toThrow('Message not found or user not authorized.');
|
).rejects.toThrow('Message not found or user not authorized.');
|
||||||
@@ -114,19 +114,45 @@ describe('Message Operations', () => {
|
|||||||
|
|
||||||
describe('deleteMessagesSince', () => {
|
describe('deleteMessagesSince', () => {
|
||||||
it('should delete messages only for the authenticated user', async () => {
|
it('should delete messages only for the authenticated user', async () => {
|
||||||
mockSchema.findOne().lean.mockResolvedValueOnce({ createdAt: new Date() });
|
const conversationId = uuidv4();
|
||||||
mockFindQuery.deleteMany.mockResolvedValueOnce({ deletedCount: 1 });
|
|
||||||
const result = await deleteMessagesSince(mockReq, {
|
// Create multiple messages in the same conversation
|
||||||
messageId: 'msg123',
|
const message1 = await saveMessage(mockReq, {
|
||||||
conversationId: 'convo123',
|
messageId: 'msg1',
|
||||||
|
conversationId,
|
||||||
|
text: 'First message',
|
||||||
|
user: 'user123',
|
||||||
});
|
});
|
||||||
expect(mockSchema.findOne).toHaveBeenCalledWith({ messageId: 'msg123', user: 'user123' });
|
|
||||||
expect(mockSchema.find).not.toHaveBeenCalled();
|
const message2 = await saveMessage(mockReq, {
|
||||||
expect(result).toBeUndefined();
|
messageId: 'msg2',
|
||||||
|
conversationId,
|
||||||
|
text: 'Second message',
|
||||||
|
user: 'user123',
|
||||||
|
});
|
||||||
|
|
||||||
|
const message3 = 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 () => {
|
it('should return undefined if no message is found', async () => {
|
||||||
mockSchema.findOne().lean.mockResolvedValueOnce(null);
|
|
||||||
const result = await deleteMessagesSince(mockReq, {
|
const result = await deleteMessagesSince(mockReq, {
|
||||||
messageId: 'nonexistent',
|
messageId: 'nonexistent',
|
||||||
conversationId: 'convo123',
|
conversationId: 'convo123',
|
||||||
@@ -137,29 +163,71 @@ describe('Message Operations', () => {
|
|||||||
|
|
||||||
describe('getMessages', () => {
|
describe('getMessages', () => {
|
||||||
it('should retrieve messages with the correct filter', async () => {
|
it('should retrieve messages with the correct filter', async () => {
|
||||||
const filter = { conversationId: 'convo123' };
|
const conversationId = uuidv4();
|
||||||
await getMessages(filter);
|
|
||||||
expect(mockSchema.find).toHaveBeenCalledWith(filter);
|
// Save some messages
|
||||||
expect(mockFindQuery.sort).toHaveBeenCalledWith({ createdAt: 1 });
|
await saveMessage(mockReq, {
|
||||||
expect(mockFindQuery.lean).toHaveBeenCalled();
|
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', () => {
|
describe('deleteMessages', () => {
|
||||||
it('should delete messages with the correct filter', async () => {
|
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' });
|
await deleteMessages({ user: 'user123' });
|
||||||
expect(mockSchema.deleteMany).toHaveBeenCalledWith({ 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', () => {
|
describe('Conversation Hijacking Prevention', () => {
|
||||||
it('should not allow editing a message in another user\'s conversation', async () => {
|
it("should not allow editing a message in another user's conversation", async () => {
|
||||||
const attackerReq = { user: { id: 'attacker123' } };
|
const attackerReq = { user: { id: 'attacker123' } };
|
||||||
const victimConversationId = 'victim-convo-123';
|
const victimConversationId = uuidv4();
|
||||||
const victimMessageId = 'victim-msg-123';
|
const victimMessageId = 'victim-msg-123';
|
||||||
|
|
||||||
mockSchema.findOneAndUpdate.mockResolvedValue(null);
|
// 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(
|
await expect(
|
||||||
updateMessage(attackerReq, {
|
updateMessage(attackerReq, {
|
||||||
messageId: victimMessageId,
|
messageId: victimMessageId,
|
||||||
@@ -168,71 +236,82 @@ describe('Message Operations', () => {
|
|||||||
}),
|
}),
|
||||||
).rejects.toThrow('Message not found or user not authorized.');
|
).rejects.toThrow('Message not found or user not authorized.');
|
||||||
|
|
||||||
expect(mockSchema.findOneAndUpdate).toHaveBeenCalledWith(
|
// Verify the original message is unchanged
|
||||||
{ messageId: victimMessageId, user: 'attacker123' },
|
const originalMessage = await Message.findOne({
|
||||||
expect.anything(),
|
messageId: victimMessageId,
|
||||||
expect.anything(),
|
user: 'victim123',
|
||||||
);
|
});
|
||||||
|
expect(originalMessage.text).toBe('Victim message');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not allow deleting messages from another user\'s conversation', async () => {
|
it("should not allow deleting messages from another user's conversation", async () => {
|
||||||
const attackerReq = { user: { id: 'attacker123' } };
|
const attackerReq = { user: { id: 'attacker123' } };
|
||||||
const victimConversationId = 'victim-convo-123';
|
const victimConversationId = uuidv4();
|
||||||
const victimMessageId = 'victim-msg-123';
|
const victimMessageId = 'victim-msg-123';
|
||||||
|
|
||||||
mockSchema.findOne().lean.mockResolvedValueOnce(null); // Simulating message not found for this user
|
// 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, {
|
const result = await deleteMessagesSince(attackerReq, {
|
||||||
messageId: victimMessageId,
|
messageId: victimMessageId,
|
||||||
conversationId: victimConversationId,
|
conversationId: victimConversationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
expect(mockSchema.findOne).toHaveBeenCalledWith({
|
|
||||||
|
// Verify the victim's message still exists
|
||||||
|
const victimMessage = await Message.findOne({
|
||||||
messageId: victimMessageId,
|
messageId: victimMessageId,
|
||||||
user: 'attacker123',
|
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 () => {
|
it("should not allow inserting a new message into another user's conversation", async () => {
|
||||||
const attackerReq = { user: { id: 'attacker123' } };
|
const attackerReq = { user: { id: 'attacker123' } };
|
||||||
const victimConversationId = uuidv4(); // Use a valid UUID
|
const victimConversationId = uuidv4();
|
||||||
|
|
||||||
await expect(
|
// Attacker tries to save a message - this should succeed but with attacker's user ID
|
||||||
saveMessage(attackerReq, {
|
const result = await saveMessage(attackerReq, {
|
||||||
conversationId: victimConversationId,
|
conversationId: victimConversationId,
|
||||||
text: 'Inserted malicious message',
|
text: 'Inserted malicious message',
|
||||||
messageId: 'new-msg-123',
|
messageId: 'new-msg-123',
|
||||||
}),
|
user: 'attacker123',
|
||||||
).resolves.not.toThrow(); // It should not throw an error
|
});
|
||||||
|
|
||||||
// Check that the message was saved with the attacker's user ID
|
expect(result).toBeTruthy();
|
||||||
expect(mockSchema.findOneAndUpdate).toHaveBeenCalledWith(
|
expect(result.user).toBe('attacker123');
|
||||||
{ messageId: 'new-msg-123', user: 'attacker123' },
|
|
||||||
expect.objectContaining({
|
// Verify the message was saved with the attacker's user ID, not as an anonymous message
|
||||||
user: 'attacker123',
|
const savedMessage = await Message.findOne({ messageId: 'new-msg-123' });
|
||||||
conversationId: victimConversationId,
|
expect(savedMessage.user).toBe('attacker123');
|
||||||
}),
|
expect(savedMessage.conversationId).toBe(victimConversationId);
|
||||||
expect.anything(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow retrieving messages from any conversation', async () => {
|
it('should allow retrieving messages from any conversation', async () => {
|
||||||
const victimConversationId = 'victim-convo-123';
|
const victimConversationId = uuidv4();
|
||||||
|
|
||||||
await getMessages({ conversationId: victimConversationId });
|
// Save a message in the victim's conversation
|
||||||
|
const victimReq = { user: { id: 'victim123' } };
|
||||||
expect(mockSchema.find).toHaveBeenCalledWith({
|
await saveMessage(victimReq, {
|
||||||
|
messageId: 'victim-msg',
|
||||||
conversationId: victimConversationId,
|
conversationId: victimConversationId,
|
||||||
|
text: 'Victim message',
|
||||||
|
user: 'victim123',
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSchema.find.mockReturnValueOnce({
|
// Anyone should be able to retrieve messages by conversation ID
|
||||||
select: jest.fn().mockReturnThis(),
|
const messages = await getMessages({ conversationId: victimConversationId });
|
||||||
sort: jest.fn().mockReturnThis(),
|
expect(messages).toHaveLength(1);
|
||||||
lean: jest.fn().mockResolvedValue([{ text: 'Test message' }]),
|
expect(messages[0].text).toBe('Victim message');
|
||||||
});
|
|
||||||
|
|
||||||
const result = await getMessages({ conversationId: victimConversationId });
|
|
||||||
expect(result).toEqual([{ text: 'Test message' }]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const Preset = require('./schema/presetSchema');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { logger } = require('~/config');
|
const { Preset } = require('~/db/models');
|
||||||
|
|
||||||
const getPreset = async (user, presetId) => {
|
const getPreset = async (user, presetId) => {
|
||||||
try {
|
try {
|
||||||
@@ -11,7 +11,6 @@ const getPreset = async (user, presetId) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
Preset,
|
|
||||||
getPreset,
|
getPreset,
|
||||||
getPresets: async (user, filter) => {
|
getPresets: async (user, filter) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
const { model } = require('mongoose');
|
|
||||||
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
|
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
|
||||||
const { projectSchema } = require('@librechat/data-schemas');
|
const { Project } = require('~/db/models');
|
||||||
|
|
||||||
const Project = model('Project', projectSchema);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve a project by ID and convert the found project document to a plain object.
|
* Retrieve a project by ID and convert the found project document to a plain object.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const mongoose = require('mongoose');
|
|
||||||
const { ObjectId } = require('mongodb');
|
const { ObjectId } = require('mongodb');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { SystemRoles, SystemCategories, Constants } = require('librechat-data-provider');
|
const { SystemRoles, SystemCategories, Constants } = require('librechat-data-provider');
|
||||||
const {
|
const {
|
||||||
getProjectByName,
|
getProjectByName,
|
||||||
@@ -7,12 +7,8 @@ const {
|
|||||||
removeGroupIdsFromProject,
|
removeGroupIdsFromProject,
|
||||||
removeGroupFromAllProjects,
|
removeGroupFromAllProjects,
|
||||||
} = require('./Project');
|
} = require('./Project');
|
||||||
const { promptGroupSchema, promptSchema } = require('@librechat/data-schemas');
|
const { PromptGroup, Prompt } = require('~/db/models');
|
||||||
const { escapeRegExp } = require('~/server/utils');
|
const { escapeRegExp } = require('~/server/utils');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const PromptGroup = mongoose.model('PromptGroup', promptGroupSchema);
|
|
||||||
const Prompt = mongoose.model('Prompt', promptSchema);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a pipeline for the aggregation to get prompt groups
|
* Create a pipeline for the aggregation to get prompt groups
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
const mongoose = require('mongoose');
|
|
||||||
const {
|
const {
|
||||||
CacheKeys,
|
CacheKeys,
|
||||||
SystemRoles,
|
SystemRoles,
|
||||||
@@ -7,11 +6,9 @@ const {
|
|||||||
permissionsSchema,
|
permissionsSchema,
|
||||||
removeNullishValues,
|
removeNullishValues,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const getLogStores = require('~/cache/getLogStores');
|
const getLogStores = require('~/cache/getLogStores');
|
||||||
const { roleSchema } = require('@librechat/data-schemas');
|
const { Role } = require('~/db/models');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const Role = mongoose.model('Role', roleSchema);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve a role by name and convert the found role document to a plain object.
|
* Retrieve a role by name and convert the found role document to a plain object.
|
||||||
@@ -173,35 +170,6 @@ async function updateAccessPermissions(roleName, permissionsUpdate) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize default roles in the system.
|
|
||||||
* Creates the default roles (ADMIN, USER) if they don't exist in the database.
|
|
||||||
* Updates existing roles with new permission types if they're missing.
|
|
||||||
*
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
const initializeRoles = async function () {
|
|
||||||
for (const roleName of [SystemRoles.ADMIN, SystemRoles.USER]) {
|
|
||||||
let role = await Role.findOne({ name: roleName });
|
|
||||||
const defaultPerms = roleDefaults[roleName].permissions;
|
|
||||||
|
|
||||||
if (!role) {
|
|
||||||
// Create new role if it doesn't exist.
|
|
||||||
role = new Role(roleDefaults[roleName]);
|
|
||||||
} else {
|
|
||||||
// Ensure role.permissions is defined.
|
|
||||||
role.permissions = role.permissions || {};
|
|
||||||
// For each permission type in defaults, add it if missing.
|
|
||||||
for (const permType of Object.keys(defaultPerms)) {
|
|
||||||
if (role.permissions[permType] == null) {
|
|
||||||
role.permissions[permType] = defaultPerms[permType];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await role.save();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Migrates roles from old schema to new schema structure.
|
* Migrates roles from old schema to new schema structure.
|
||||||
* This can be called directly to fix existing roles.
|
* This can be called directly to fix existing roles.
|
||||||
@@ -282,10 +250,8 @@ const migrateRoleSchema = async function (roleName) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
Role,
|
|
||||||
getRoleByName,
|
getRoleByName,
|
||||||
initializeRoles,
|
|
||||||
updateRoleByName,
|
updateRoleByName,
|
||||||
updateAccessPermissions,
|
|
||||||
migrateRoleSchema,
|
migrateRoleSchema,
|
||||||
|
updateAccessPermissions,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ const {
|
|||||||
roleDefaults,
|
roleDefaults,
|
||||||
PermissionTypes,
|
PermissionTypes,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { Role, getRoleByName, updateAccessPermissions, initializeRoles } = require('~/models/Role');
|
const { getRoleByName, updateAccessPermissions } = require('~/models/Role');
|
||||||
const getLogStores = require('~/cache/getLogStores');
|
const getLogStores = require('~/cache/getLogStores');
|
||||||
|
const { initializeRoles } = require('~/models');
|
||||||
|
const { Role } = require('~/db/models');
|
||||||
|
|
||||||
// Mock the cache
|
// Mock the cache
|
||||||
jest.mock('~/cache/getLogStores', () =>
|
jest.mock('~/cache/getLogStores', () =>
|
||||||
|
|||||||
@@ -1,275 +0,0 @@
|
|||||||
const mongoose = require('mongoose');
|
|
||||||
const signPayload = require('~/server/services/signPayload');
|
|
||||||
const { hashToken } = require('~/server/utils/crypto');
|
|
||||||
const { sessionSchema } = require('@librechat/data-schemas');
|
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const Session = mongoose.model('Session', sessionSchema);
|
|
||||||
|
|
||||||
const { REFRESH_TOKEN_EXPIRY } = process.env ?? {};
|
|
||||||
const expires = eval(REFRESH_TOKEN_EXPIRY) ?? 1000 * 60 * 60 * 24 * 7; // 7 days default
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error class for Session-related errors
|
|
||||||
*/
|
|
||||||
class SessionError extends Error {
|
|
||||||
constructor(message, code = 'SESSION_ERROR') {
|
|
||||||
super(message);
|
|
||||||
this.name = 'SessionError';
|
|
||||||
this.code = code;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new session for a user
|
|
||||||
* @param {string} userId - The ID of the user
|
|
||||||
* @param {Object} options - Additional options for session creation
|
|
||||||
* @param {Date} options.expiration - Custom expiration date
|
|
||||||
* @returns {Promise<{session: Session, refreshToken: string}>}
|
|
||||||
* @throws {SessionError}
|
|
||||||
*/
|
|
||||||
const createSession = async (userId, options = {}) => {
|
|
||||||
if (!userId) {
|
|
||||||
throw new SessionError('User ID is required', 'INVALID_USER_ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const session = new Session({
|
|
||||||
user: userId,
|
|
||||||
expiration: options.expiration || new Date(Date.now() + expires),
|
|
||||||
});
|
|
||||||
const refreshToken = await generateRefreshToken(session);
|
|
||||||
return { session, refreshToken };
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[createSession] Error creating session:', error);
|
|
||||||
throw new SessionError('Failed to create session', 'CREATE_SESSION_FAILED');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds a session by various parameters
|
|
||||||
* @param {Object} params - Search parameters
|
|
||||||
* @param {string} [params.refreshToken] - The refresh token to search by
|
|
||||||
* @param {string} [params.userId] - The user ID to search by
|
|
||||||
* @param {string} [params.sessionId] - The session ID to search by
|
|
||||||
* @param {Object} [options] - Additional options
|
|
||||||
* @param {boolean} [options.lean=true] - Whether to return plain objects instead of documents
|
|
||||||
* @returns {Promise<Session|null>}
|
|
||||||
* @throws {SessionError}
|
|
||||||
*/
|
|
||||||
const findSession = async (params, options = { lean: true }) => {
|
|
||||||
try {
|
|
||||||
const query = {};
|
|
||||||
|
|
||||||
if (!params.refreshToken && !params.userId && !params.sessionId) {
|
|
||||||
throw new SessionError('At least one search parameter is required', 'INVALID_SEARCH_PARAMS');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.refreshToken) {
|
|
||||||
const tokenHash = await hashToken(params.refreshToken);
|
|
||||||
query.refreshTokenHash = tokenHash;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.userId) {
|
|
||||||
query.user = params.userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.sessionId) {
|
|
||||||
const sessionId = params.sessionId.sessionId || params.sessionId;
|
|
||||||
if (!mongoose.Types.ObjectId.isValid(sessionId)) {
|
|
||||||
throw new SessionError('Invalid session ID format', 'INVALID_SESSION_ID');
|
|
||||||
}
|
|
||||||
query._id = sessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add expiration check to only return valid sessions
|
|
||||||
query.expiration = { $gt: new Date() };
|
|
||||||
|
|
||||||
const sessionQuery = Session.findOne(query);
|
|
||||||
|
|
||||||
if (options.lean) {
|
|
||||||
return await sessionQuery.lean();
|
|
||||||
}
|
|
||||||
|
|
||||||
return await sessionQuery.exec();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[findSession] Error finding session:', error);
|
|
||||||
throw new SessionError('Failed to find session', 'FIND_SESSION_FAILED');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates session expiration
|
|
||||||
* @param {Session|string} session - The session or session ID to update
|
|
||||||
* @param {Date} [newExpiration] - Optional new expiration date
|
|
||||||
* @returns {Promise<Session>}
|
|
||||||
* @throws {SessionError}
|
|
||||||
*/
|
|
||||||
const updateExpiration = async (session, newExpiration) => {
|
|
||||||
try {
|
|
||||||
const sessionDoc = typeof session === 'string' ? await Session.findById(session) : session;
|
|
||||||
|
|
||||||
if (!sessionDoc) {
|
|
||||||
throw new SessionError('Session not found', 'SESSION_NOT_FOUND');
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionDoc.expiration = newExpiration || new Date(Date.now() + expires);
|
|
||||||
return await sessionDoc.save();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[updateExpiration] Error updating session:', error);
|
|
||||||
throw new SessionError('Failed to update session expiration', 'UPDATE_EXPIRATION_FAILED');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a session by refresh token or session ID
|
|
||||||
* @param {Object} params - Delete parameters
|
|
||||||
* @param {string} [params.refreshToken] - The refresh token of the session to delete
|
|
||||||
* @param {string} [params.sessionId] - The ID of the session to delete
|
|
||||||
* @returns {Promise<Object>}
|
|
||||||
* @throws {SessionError}
|
|
||||||
*/
|
|
||||||
const deleteSession = async (params) => {
|
|
||||||
try {
|
|
||||||
if (!params.refreshToken && !params.sessionId) {
|
|
||||||
throw new SessionError(
|
|
||||||
'Either refreshToken or sessionId is required',
|
|
||||||
'INVALID_DELETE_PARAMS',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = {};
|
|
||||||
|
|
||||||
if (params.refreshToken) {
|
|
||||||
query.refreshTokenHash = await hashToken(params.refreshToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.sessionId) {
|
|
||||||
query._id = params.sessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await Session.deleteOne(query);
|
|
||||||
|
|
||||||
if (result.deletedCount === 0) {
|
|
||||||
logger.warn('[deleteSession] No session found to delete');
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[deleteSession] Error deleting session:', error);
|
|
||||||
throw new SessionError('Failed to delete session', 'DELETE_SESSION_FAILED');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes all sessions for a user
|
|
||||||
* @param {string} userId - The ID of the user
|
|
||||||
* @param {Object} [options] - Additional options
|
|
||||||
* @param {boolean} [options.excludeCurrentSession] - Whether to exclude the current session
|
|
||||||
* @param {string} [options.currentSessionId] - The ID of the current session to exclude
|
|
||||||
* @returns {Promise<Object>}
|
|
||||||
* @throws {SessionError}
|
|
||||||
*/
|
|
||||||
const deleteAllUserSessions = async (userId, options = {}) => {
|
|
||||||
try {
|
|
||||||
if (!userId) {
|
|
||||||
throw new SessionError('User ID is required', 'INVALID_USER_ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract userId if it's passed as an object
|
|
||||||
const userIdString = userId.userId || userId;
|
|
||||||
|
|
||||||
if (!mongoose.Types.ObjectId.isValid(userIdString)) {
|
|
||||||
throw new SessionError('Invalid user ID format', 'INVALID_USER_ID_FORMAT');
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = { user: userIdString };
|
|
||||||
|
|
||||||
if (options.excludeCurrentSession && options.currentSessionId) {
|
|
||||||
query._id = { $ne: options.currentSessionId };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await Session.deleteMany(query);
|
|
||||||
|
|
||||||
if (result.deletedCount > 0) {
|
|
||||||
logger.debug(
|
|
||||||
`[deleteAllUserSessions] Deleted ${result.deletedCount} sessions for user ${userIdString}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[deleteAllUserSessions] Error deleting user sessions:', error);
|
|
||||||
throw new SessionError('Failed to delete user sessions', 'DELETE_ALL_SESSIONS_FAILED');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a refresh token for a session
|
|
||||||
* @param {Session} session - The session to generate a token for
|
|
||||||
* @returns {Promise<string>}
|
|
||||||
* @throws {SessionError}
|
|
||||||
*/
|
|
||||||
const generateRefreshToken = async (session) => {
|
|
||||||
if (!session || !session.user) {
|
|
||||||
throw new SessionError('Invalid session object', 'INVALID_SESSION');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const expiresIn = session.expiration ? session.expiration.getTime() : Date.now() + expires;
|
|
||||||
|
|
||||||
if (!session.expiration) {
|
|
||||||
session.expiration = new Date(expiresIn);
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshToken = await signPayload({
|
|
||||||
payload: {
|
|
||||||
id: session.user,
|
|
||||||
sessionId: session._id,
|
|
||||||
},
|
|
||||||
secret: process.env.JWT_REFRESH_SECRET,
|
|
||||||
expirationTime: Math.floor((expiresIn - Date.now()) / 1000),
|
|
||||||
});
|
|
||||||
|
|
||||||
session.refreshTokenHash = await hashToken(refreshToken);
|
|
||||||
await session.save();
|
|
||||||
|
|
||||||
return refreshToken;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[generateRefreshToken] Error generating refresh token:', error);
|
|
||||||
throw new SessionError('Failed to generate refresh token', 'GENERATE_TOKEN_FAILED');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Counts active sessions for a user
|
|
||||||
* @param {string} userId - The ID of the user
|
|
||||||
* @returns {Promise<number>}
|
|
||||||
* @throws {SessionError}
|
|
||||||
*/
|
|
||||||
const countActiveSessions = async (userId) => {
|
|
||||||
try {
|
|
||||||
if (!userId) {
|
|
||||||
throw new SessionError('User ID is required', 'INVALID_USER_ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await Session.countDocuments({
|
|
||||||
user: userId,
|
|
||||||
expiration: { $gt: new Date() },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[countActiveSessions] Error counting active sessions:', error);
|
|
||||||
throw new SessionError('Failed to count active sessions', 'COUNT_SESSIONS_FAILED');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
createSession,
|
|
||||||
findSession,
|
|
||||||
updateExpiration,
|
|
||||||
deleteSession,
|
|
||||||
deleteAllUserSessions,
|
|
||||||
generateRefreshToken,
|
|
||||||
countActiveSessions,
|
|
||||||
SessionError,
|
|
||||||
};
|
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
const mongoose = require('mongoose');
|
|
||||||
const { nanoid } = require('nanoid');
|
const { nanoid } = require('nanoid');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { Constants } = require('librechat-data-provider');
|
const { Constants } = require('librechat-data-provider');
|
||||||
const { Conversation } = require('~/models/Conversation');
|
const { Conversation, SharedLink } = require('~/db/models');
|
||||||
const { shareSchema } = require('@librechat/data-schemas');
|
|
||||||
const SharedLink = mongoose.model('SharedLink', shareSchema);
|
|
||||||
const { getMessages } = require('./Message');
|
const { getMessages } = require('./Message');
|
||||||
const logger = require('~/config/winston');
|
|
||||||
|
|
||||||
class ShareServiceError extends Error {
|
class ShareServiceError extends Error {
|
||||||
constructor(message, code) {
|
constructor(message, code) {
|
||||||
@@ -202,7 +199,6 @@ async function createSharedLink(user, conversationId) {
|
|||||||
if (!user || !conversationId) {
|
if (!user || !conversationId) {
|
||||||
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
|
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [existingShare, conversationMessages] = await Promise.all([
|
const [existingShare, conversationMessages] = await Promise.all([
|
||||||
SharedLink.findOne({ conversationId, isPublic: true }).select('-_id -__v -user').lean(),
|
SharedLink.findOne({ conversationId, isPublic: true }).select('-_id -__v -user').lean(),
|
||||||
@@ -340,7 +336,6 @@ async function deleteSharedLink(user, shareId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
SharedLink,
|
|
||||||
getSharedLink,
|
getSharedLink,
|
||||||
getSharedLinks,
|
getSharedLinks,
|
||||||
createSharedLink,
|
createSharedLink,
|
||||||
|
|||||||
@@ -1,158 +1,5 @@
|
|||||||
const mongoose = require('mongoose');
|
const { findToken, updateToken, createToken } = require('~/models');
|
||||||
const { encryptV2 } = require('~/server/utils/crypto');
|
const { encryptV2 } = require('~/server/utils/crypto');
|
||||||
const { tokenSchema } = require('@librechat/data-schemas');
|
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Token model.
|
|
||||||
* @type {mongoose.Model}
|
|
||||||
*/
|
|
||||||
const Token = mongoose.model('Token', tokenSchema);
|
|
||||||
/**
|
|
||||||
* Fixes the indexes for the Token collection from legacy TTL indexes to the new expiresAt index.
|
|
||||||
*/
|
|
||||||
async function fixIndexes() {
|
|
||||||
try {
|
|
||||||
if (
|
|
||||||
process.env.NODE_ENV === 'CI' ||
|
|
||||||
process.env.NODE_ENV === 'development' ||
|
|
||||||
process.env.NODE_ENV === 'test'
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const indexes = await Token.collection.indexes();
|
|
||||||
logger.debug('Existing Token Indexes:', JSON.stringify(indexes, null, 2));
|
|
||||||
const unwantedTTLIndexes = indexes.filter(
|
|
||||||
(index) => index.key.createdAt === 1 && index.expireAfterSeconds !== undefined,
|
|
||||||
);
|
|
||||||
if (unwantedTTLIndexes.length === 0) {
|
|
||||||
logger.debug('No unwanted Token indexes found.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const index of unwantedTTLIndexes) {
|
|
||||||
logger.debug(`Dropping unwanted Token index: ${index.name}`);
|
|
||||||
await Token.collection.dropIndex(index.name);
|
|
||||||
logger.debug(`Dropped Token index: ${index.name}`);
|
|
||||||
}
|
|
||||||
logger.debug('Token index cleanup completed successfully.');
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('An error occurred while fixing Token indexes:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fixIndexes();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new Token instance.
|
|
||||||
* @param {Object} tokenData - The data for the new Token.
|
|
||||||
* @param {mongoose.Types.ObjectId} tokenData.userId - The user's ID. It is required.
|
|
||||||
* @param {String} tokenData.email - The user's email.
|
|
||||||
* @param {String} tokenData.token - The token. It is required.
|
|
||||||
* @param {Number} tokenData.expiresIn - The number of seconds until the token expires.
|
|
||||||
* @returns {Promise<mongoose.Document>} The new Token instance.
|
|
||||||
* @throws Will throw an error if token creation fails.
|
|
||||||
*/
|
|
||||||
async function createToken(tokenData) {
|
|
||||||
try {
|
|
||||||
const currentTime = new Date();
|
|
||||||
const expiresAt = new Date(currentTime.getTime() + tokenData.expiresIn * 1000);
|
|
||||||
|
|
||||||
const newTokenData = {
|
|
||||||
...tokenData,
|
|
||||||
createdAt: currentTime,
|
|
||||||
expiresAt,
|
|
||||||
};
|
|
||||||
|
|
||||||
return await Token.create(newTokenData);
|
|
||||||
} catch (error) {
|
|
||||||
logger.debug('An error occurred while creating token:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds a Token document that matches the provided query.
|
|
||||||
* @param {Object} query - The query to match against.
|
|
||||||
* @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user.
|
|
||||||
* @param {String} query.token - The token value.
|
|
||||||
* @param {String} [query.email] - The email of the user.
|
|
||||||
* @param {String} [query.identifier] - Unique, alternative identifier for the token.
|
|
||||||
* @returns {Promise<Object|null>} The matched Token document, or null if not found.
|
|
||||||
* @throws Will throw an error if the find operation fails.
|
|
||||||
*/
|
|
||||||
async function findToken(query) {
|
|
||||||
try {
|
|
||||||
const conditions = [];
|
|
||||||
|
|
||||||
if (query.userId) {
|
|
||||||
conditions.push({ userId: query.userId });
|
|
||||||
}
|
|
||||||
if (query.token) {
|
|
||||||
conditions.push({ token: query.token });
|
|
||||||
}
|
|
||||||
if (query.email) {
|
|
||||||
conditions.push({ email: query.email });
|
|
||||||
}
|
|
||||||
if (query.identifier) {
|
|
||||||
conditions.push({ identifier: query.identifier });
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = await Token.findOne({
|
|
||||||
$and: conditions,
|
|
||||||
}).lean();
|
|
||||||
|
|
||||||
return token;
|
|
||||||
} catch (error) {
|
|
||||||
logger.debug('An error occurred while finding token:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates a Token document that matches the provided query.
|
|
||||||
* @param {Object} query - The query to match against.
|
|
||||||
* @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user.
|
|
||||||
* @param {String} query.token - The token value.
|
|
||||||
* @param {String} [query.email] - The email of the user.
|
|
||||||
* @param {String} [query.identifier] - Unique, alternative identifier for the token.
|
|
||||||
* @param {Object} updateData - The data to update the Token with.
|
|
||||||
* @returns {Promise<mongoose.Document|null>} The updated Token document, or null if not found.
|
|
||||||
* @throws Will throw an error if the update operation fails.
|
|
||||||
*/
|
|
||||||
async function updateToken(query, updateData) {
|
|
||||||
try {
|
|
||||||
return await Token.findOneAndUpdate(query, updateData, { new: true });
|
|
||||||
} catch (error) {
|
|
||||||
logger.debug('An error occurred while updating token:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes all Token documents that match the provided token, user ID, or email.
|
|
||||||
* @param {Object} query - The query to match against.
|
|
||||||
* @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user.
|
|
||||||
* @param {String} query.token - The token value.
|
|
||||||
* @param {String} [query.email] - The email of the user.
|
|
||||||
* @param {String} [query.identifier] - Unique, alternative identifier for the token.
|
|
||||||
* @returns {Promise<Object>} The result of the delete operation.
|
|
||||||
* @throws Will throw an error if the delete operation fails.
|
|
||||||
*/
|
|
||||||
async function deleteTokens(query) {
|
|
||||||
try {
|
|
||||||
return await Token.deleteMany({
|
|
||||||
$or: [
|
|
||||||
{ userId: query.userId },
|
|
||||||
{ token: query.token },
|
|
||||||
{ email: query.email },
|
|
||||||
{ identifier: query.identifier },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.debug('An error occurred while deleting tokens:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the OAuth token by creating or updating the token.
|
* Handles the OAuth token by creating or updating the token.
|
||||||
@@ -191,9 +38,5 @@ async function handleOAuthToken({
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
findToken,
|
|
||||||
createToken,
|
|
||||||
updateToken,
|
|
||||||
deleteTokens,
|
|
||||||
handleOAuthToken,
|
handleOAuthToken,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
const mongoose = require('mongoose');
|
const { ToolCall } = require('~/db/models');
|
||||||
const { toolCallSchema } = require('@librechat/data-schemas');
|
|
||||||
const ToolCall = mongoose.model('ToolCall', toolCallSchema);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new tool call
|
* Create a new tool call
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
const mongoose = require('mongoose');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { transactionSchema } = require('@librechat/data-schemas');
|
|
||||||
const { getBalanceConfig } = require('~/server/services/Config');
|
const { getBalanceConfig } = require('~/server/services/Config');
|
||||||
const { getMultiplier, getCacheMultiplier } = require('./tx');
|
const { getMultiplier, getCacheMultiplier } = require('./tx');
|
||||||
const { logger } = require('~/config');
|
const { Transaction, Balance } = require('~/db/models');
|
||||||
const Balance = require('./Balance');
|
|
||||||
|
|
||||||
const cancelRate = 1.15;
|
const cancelRate = 1.15;
|
||||||
|
|
||||||
@@ -140,19 +138,19 @@ const updateBalance = async ({ user, incrementValue, setValues }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Method to calculate and set the tokenValue for a transaction */
|
/** Method to calculate and set the tokenValue for a transaction */
|
||||||
transactionSchema.methods.calculateTokenValue = function () {
|
function calculateTokenValue(txn) {
|
||||||
if (!this.valueKey || !this.tokenType) {
|
if (!txn.valueKey || !txn.tokenType) {
|
||||||
this.tokenValue = this.rawAmount;
|
txn.tokenValue = txn.rawAmount;
|
||||||
}
|
}
|
||||||
const { valueKey, tokenType, model, endpointTokenConfig } = this;
|
const { valueKey, tokenType, model, endpointTokenConfig } = txn;
|
||||||
const multiplier = Math.abs(getMultiplier({ valueKey, tokenType, model, endpointTokenConfig }));
|
const multiplier = Math.abs(getMultiplier({ valueKey, tokenType, model, endpointTokenConfig }));
|
||||||
this.rate = multiplier;
|
txn.rate = multiplier;
|
||||||
this.tokenValue = this.rawAmount * multiplier;
|
txn.tokenValue = txn.rawAmount * multiplier;
|
||||||
if (this.context && this.tokenType === 'completion' && this.context === 'incomplete') {
|
if (txn.context && txn.tokenType === 'completion' && txn.context === 'incomplete') {
|
||||||
this.tokenValue = Math.ceil(this.tokenValue * cancelRate);
|
txn.tokenValue = Math.ceil(txn.tokenValue * cancelRate);
|
||||||
this.rate *= cancelRate;
|
txn.rate *= cancelRate;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* New static method to create an auto-refill transaction that does NOT trigger a balance update.
|
* New static method to create an auto-refill transaction that does NOT trigger a balance update.
|
||||||
@@ -163,13 +161,13 @@ transactionSchema.methods.calculateTokenValue = function () {
|
|||||||
* @param {number} txData.rawAmount - The raw amount of tokens.
|
* @param {number} txData.rawAmount - The raw amount of tokens.
|
||||||
* @returns {Promise<object>} - The created transaction.
|
* @returns {Promise<object>} - The created transaction.
|
||||||
*/
|
*/
|
||||||
transactionSchema.statics.createAutoRefillTransaction = async function (txData) {
|
async function createAutoRefillTransaction(txData) {
|
||||||
if (txData.rawAmount != null && isNaN(txData.rawAmount)) {
|
if (txData.rawAmount != null && isNaN(txData.rawAmount)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const transaction = new this(txData);
|
const transaction = new Transaction(txData);
|
||||||
transaction.endpointTokenConfig = txData.endpointTokenConfig;
|
transaction.endpointTokenConfig = txData.endpointTokenConfig;
|
||||||
transaction.calculateTokenValue();
|
calculateTokenValue(transaction);
|
||||||
await transaction.save();
|
await transaction.save();
|
||||||
|
|
||||||
const balanceResponse = await updateBalance({
|
const balanceResponse = await updateBalance({
|
||||||
@@ -185,21 +183,20 @@ transactionSchema.statics.createAutoRefillTransaction = async function (txData)
|
|||||||
logger.debug('[Balance.check] Auto-refill performed', result);
|
logger.debug('[Balance.check] Auto-refill performed', result);
|
||||||
result.transaction = transaction;
|
result.transaction = transaction;
|
||||||
return result;
|
return result;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Static method to create a transaction and update the balance
|
* Static method to create a transaction and update the balance
|
||||||
* @param {txData} txData - Transaction data.
|
* @param {txData} txData - Transaction data.
|
||||||
*/
|
*/
|
||||||
transactionSchema.statics.create = async function (txData) {
|
async function createTransaction(txData) {
|
||||||
const Transaction = this;
|
|
||||||
if (txData.rawAmount != null && isNaN(txData.rawAmount)) {
|
if (txData.rawAmount != null && isNaN(txData.rawAmount)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const transaction = new Transaction(txData);
|
const transaction = new Transaction(txData);
|
||||||
transaction.endpointTokenConfig = txData.endpointTokenConfig;
|
transaction.endpointTokenConfig = txData.endpointTokenConfig;
|
||||||
transaction.calculateTokenValue();
|
calculateTokenValue(transaction);
|
||||||
|
|
||||||
await transaction.save();
|
await transaction.save();
|
||||||
|
|
||||||
@@ -209,7 +206,6 @@ transactionSchema.statics.create = async function (txData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let incrementValue = transaction.tokenValue;
|
let incrementValue = transaction.tokenValue;
|
||||||
|
|
||||||
const balanceResponse = await updateBalance({
|
const balanceResponse = await updateBalance({
|
||||||
user: transaction.user,
|
user: transaction.user,
|
||||||
incrementValue,
|
incrementValue,
|
||||||
@@ -221,21 +217,19 @@ transactionSchema.statics.create = async function (txData) {
|
|||||||
balance: balanceResponse.tokenCredits,
|
balance: balanceResponse.tokenCredits,
|
||||||
[transaction.tokenType]: incrementValue,
|
[transaction.tokenType]: incrementValue,
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Static method to create a structured transaction and update the balance
|
* Static method to create a structured transaction and update the balance
|
||||||
* @param {txData} txData - Transaction data.
|
* @param {txData} txData - Transaction data.
|
||||||
*/
|
*/
|
||||||
transactionSchema.statics.createStructured = async function (txData) {
|
async function createStructuredTransaction(txData) {
|
||||||
const Transaction = this;
|
|
||||||
|
|
||||||
const transaction = new Transaction({
|
const transaction = new Transaction({
|
||||||
...txData,
|
...txData,
|
||||||
endpointTokenConfig: txData.endpointTokenConfig,
|
endpointTokenConfig: txData.endpointTokenConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
transaction.calculateStructuredTokenValue();
|
calculateStructuredTokenValue(transaction);
|
||||||
|
|
||||||
await transaction.save();
|
await transaction.save();
|
||||||
|
|
||||||
@@ -257,71 +251,69 @@ transactionSchema.statics.createStructured = async function (txData) {
|
|||||||
balance: balanceResponse.tokenCredits,
|
balance: balanceResponse.tokenCredits,
|
||||||
[transaction.tokenType]: incrementValue,
|
[transaction.tokenType]: incrementValue,
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
/** Method to calculate token value for structured tokens */
|
/** Method to calculate token value for structured tokens */
|
||||||
transactionSchema.methods.calculateStructuredTokenValue = function () {
|
function calculateStructuredTokenValue(txn) {
|
||||||
if (!this.tokenType) {
|
if (!txn.tokenType) {
|
||||||
this.tokenValue = this.rawAmount;
|
txn.tokenValue = txn.rawAmount;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { model, endpointTokenConfig } = this;
|
const { model, endpointTokenConfig } = txn;
|
||||||
|
|
||||||
if (this.tokenType === 'prompt') {
|
if (txn.tokenType === 'prompt') {
|
||||||
const inputMultiplier = getMultiplier({ tokenType: 'prompt', model, endpointTokenConfig });
|
const inputMultiplier = getMultiplier({ tokenType: 'prompt', model, endpointTokenConfig });
|
||||||
const writeMultiplier =
|
const writeMultiplier =
|
||||||
getCacheMultiplier({ cacheType: 'write', model, endpointTokenConfig }) ?? inputMultiplier;
|
getCacheMultiplier({ cacheType: 'write', model, endpointTokenConfig }) ?? inputMultiplier;
|
||||||
const readMultiplier =
|
const readMultiplier =
|
||||||
getCacheMultiplier({ cacheType: 'read', model, endpointTokenConfig }) ?? inputMultiplier;
|
getCacheMultiplier({ cacheType: 'read', model, endpointTokenConfig }) ?? inputMultiplier;
|
||||||
|
|
||||||
this.rateDetail = {
|
txn.rateDetail = {
|
||||||
input: inputMultiplier,
|
input: inputMultiplier,
|
||||||
write: writeMultiplier,
|
write: writeMultiplier,
|
||||||
read: readMultiplier,
|
read: readMultiplier,
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalPromptTokens =
|
const totalPromptTokens =
|
||||||
Math.abs(this.inputTokens || 0) +
|
Math.abs(txn.inputTokens || 0) +
|
||||||
Math.abs(this.writeTokens || 0) +
|
Math.abs(txn.writeTokens || 0) +
|
||||||
Math.abs(this.readTokens || 0);
|
Math.abs(txn.readTokens || 0);
|
||||||
|
|
||||||
if (totalPromptTokens > 0) {
|
if (totalPromptTokens > 0) {
|
||||||
this.rate =
|
txn.rate =
|
||||||
(Math.abs(inputMultiplier * (this.inputTokens || 0)) +
|
(Math.abs(inputMultiplier * (txn.inputTokens || 0)) +
|
||||||
Math.abs(writeMultiplier * (this.writeTokens || 0)) +
|
Math.abs(writeMultiplier * (txn.writeTokens || 0)) +
|
||||||
Math.abs(readMultiplier * (this.readTokens || 0))) /
|
Math.abs(readMultiplier * (txn.readTokens || 0))) /
|
||||||
totalPromptTokens;
|
totalPromptTokens;
|
||||||
} else {
|
} else {
|
||||||
this.rate = Math.abs(inputMultiplier); // Default to input rate if no tokens
|
txn.rate = Math.abs(inputMultiplier); // Default to input rate if no tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
this.tokenValue = -(
|
txn.tokenValue = -(
|
||||||
Math.abs(this.inputTokens || 0) * inputMultiplier +
|
Math.abs(txn.inputTokens || 0) * inputMultiplier +
|
||||||
Math.abs(this.writeTokens || 0) * writeMultiplier +
|
Math.abs(txn.writeTokens || 0) * writeMultiplier +
|
||||||
Math.abs(this.readTokens || 0) * readMultiplier
|
Math.abs(txn.readTokens || 0) * readMultiplier
|
||||||
);
|
);
|
||||||
|
|
||||||
this.rawAmount = -totalPromptTokens;
|
txn.rawAmount = -totalPromptTokens;
|
||||||
} else if (this.tokenType === 'completion') {
|
} else if (txn.tokenType === 'completion') {
|
||||||
const multiplier = getMultiplier({ tokenType: this.tokenType, model, endpointTokenConfig });
|
const multiplier = getMultiplier({ tokenType: txn.tokenType, model, endpointTokenConfig });
|
||||||
this.rate = Math.abs(multiplier);
|
txn.rate = Math.abs(multiplier);
|
||||||
this.tokenValue = -Math.abs(this.rawAmount) * multiplier;
|
txn.tokenValue = -Math.abs(txn.rawAmount) * multiplier;
|
||||||
this.rawAmount = -Math.abs(this.rawAmount);
|
txn.rawAmount = -Math.abs(txn.rawAmount);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.context && this.tokenType === 'completion' && this.context === 'incomplete') {
|
if (txn.context && txn.tokenType === 'completion' && txn.context === 'incomplete') {
|
||||||
this.tokenValue = Math.ceil(this.tokenValue * cancelRate);
|
txn.tokenValue = Math.ceil(txn.tokenValue * cancelRate);
|
||||||
this.rate *= cancelRate;
|
txn.rate *= cancelRate;
|
||||||
if (this.rateDetail) {
|
if (txn.rateDetail) {
|
||||||
this.rateDetail = Object.fromEntries(
|
txn.rateDetail = Object.fromEntries(
|
||||||
Object.entries(this.rateDetail).map(([k, v]) => [k, v * cancelRate]),
|
Object.entries(txn.rateDetail).map(([k, v]) => [k, v * cancelRate]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const Transaction = mongoose.model('Transaction', transactionSchema);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queries and retrieves transactions based on a given filter.
|
* Queries and retrieves transactions based on a given filter.
|
||||||
@@ -340,4 +332,9 @@ async function getTransactions(filter) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { Transaction, getTransactions };
|
module.exports = {
|
||||||
|
getTransactions,
|
||||||
|
createTransaction,
|
||||||
|
createAutoRefillTransaction,
|
||||||
|
createStructuredTransaction,
|
||||||
|
};
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ const { MongoMemoryServer } = require('mongodb-memory-server');
|
|||||||
const { spendTokens, spendStructuredTokens } = require('./spendTokens');
|
const { spendTokens, spendStructuredTokens } = require('./spendTokens');
|
||||||
const { getBalanceConfig } = require('~/server/services/Config');
|
const { getBalanceConfig } = require('~/server/services/Config');
|
||||||
const { getMultiplier, getCacheMultiplier } = require('./tx');
|
const { getMultiplier, getCacheMultiplier } = require('./tx');
|
||||||
const { Transaction } = require('./Transaction');
|
const { createTransaction } = require('./Transaction');
|
||||||
const Balance = require('./Balance');
|
const { Balance } = require('~/db/models');
|
||||||
|
|
||||||
// Mock the custom config module so we can control the balance flag.
|
// Mock the custom config module so we can control the balance flag.
|
||||||
jest.mock('~/server/services/Config');
|
jest.mock('~/server/services/Config');
|
||||||
|
|
||||||
let mongoServer;
|
let mongoServer;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
mongoServer = await MongoMemoryServer.create();
|
mongoServer = await MongoMemoryServer.create();
|
||||||
const mongoUri = mongoServer.getUri();
|
const mongoUri = mongoServer.getUri();
|
||||||
@@ -368,7 +367,7 @@ describe('NaN Handling Tests', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const result = await Transaction.create(txData);
|
const result = await createTransaction(txData);
|
||||||
|
|
||||||
// Assert: No transaction should be created and balance remains unchanged.
|
// Assert: No transaction should be created and balance remains unchanged.
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
const mongoose = require('mongoose');
|
|
||||||
const { userSchema } = require('@librechat/data-schemas');
|
|
||||||
|
|
||||||
const User = mongoose.model('User', userSchema);
|
|
||||||
|
|
||||||
module.exports = User;
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { ViolationTypes } = require('librechat-data-provider');
|
const { ViolationTypes } = require('librechat-data-provider');
|
||||||
const { Transaction } = require('./Transaction');
|
const { createAutoRefillTransaction } = require('./Transaction');
|
||||||
const { logViolation } = require('~/cache');
|
const { logViolation } = require('~/cache');
|
||||||
const { getMultiplier } = require('./tx');
|
const { getMultiplier } = require('./tx');
|
||||||
const { logger } = require('~/config');
|
const { Balance } = require('~/db/models');
|
||||||
const Balance = require('./Balance');
|
|
||||||
|
|
||||||
function isInvalidDate(date) {
|
function isInvalidDate(date) {
|
||||||
return isNaN(date);
|
return isNaN(date);
|
||||||
@@ -60,7 +60,7 @@ const checkBalanceRecord = async function ({
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
/** @type {{ rate: number, user: string, balance: number, transaction: import('@librechat/data-schemas').ITransaction}} */
|
/** @type {{ rate: number, user: string, balance: number, transaction: import('@librechat/data-schemas').ITransaction}} */
|
||||||
const result = await Transaction.createAutoRefillTransaction({
|
const result = await createAutoRefillTransaction({
|
||||||
user: user,
|
user: user,
|
||||||
tokenType: 'credits',
|
tokenType: 'credits',
|
||||||
context: 'autoRefill',
|
context: 'autoRefill',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
const { Message, getMessages, bulkSaveMessages } = require('./Message');
|
const { getMessages, bulkSaveMessages } = require('./Message');
|
||||||
|
const { Message } = require('~/db/models');
|
||||||
|
|
||||||
// Original version of buildTree function
|
// Original version of buildTree function
|
||||||
function buildTree({ messages, fileMap }) {
|
function buildTree({ messages, fileMap }) {
|
||||||
@@ -42,7 +43,6 @@ function buildTree({ messages, fileMap }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mongod;
|
let mongod;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
mongod = await MongoMemoryServer.create();
|
mongod = await MongoMemoryServer.create();
|
||||||
const uri = mongod.getUri();
|
const uri = mongod.getUri();
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
const {
|
const mongoose = require('mongoose');
|
||||||
comparePassword,
|
const { createMethods } = require('@librechat/data-schemas');
|
||||||
deleteUserById,
|
const methods = createMethods(mongoose);
|
||||||
generateToken,
|
const { comparePassword } = require('./userMethods');
|
||||||
getUserById,
|
|
||||||
updateUser,
|
|
||||||
createUser,
|
|
||||||
countUsers,
|
|
||||||
findUser,
|
|
||||||
} = require('./userMethods');
|
|
||||||
const {
|
const {
|
||||||
findFileById,
|
findFileById,
|
||||||
createFile,
|
createFile,
|
||||||
@@ -26,32 +20,12 @@ const {
|
|||||||
deleteMessagesSince,
|
deleteMessagesSince,
|
||||||
deleteMessages,
|
deleteMessages,
|
||||||
} = require('./Message');
|
} = require('./Message');
|
||||||
const {
|
|
||||||
createSession,
|
|
||||||
findSession,
|
|
||||||
updateExpiration,
|
|
||||||
deleteSession,
|
|
||||||
deleteAllUserSessions,
|
|
||||||
generateRefreshToken,
|
|
||||||
countActiveSessions,
|
|
||||||
} = require('./Session');
|
|
||||||
const { getConvoTitle, getConvo, saveConvo, deleteConvos } = require('./Conversation');
|
const { getConvoTitle, getConvo, saveConvo, deleteConvos } = require('./Conversation');
|
||||||
const { getPreset, getPresets, savePreset, deletePresets } = require('./Preset');
|
const { getPreset, getPresets, savePreset, deletePresets } = require('./Preset');
|
||||||
const { createToken, findToken, updateToken, deleteTokens } = require('./Token');
|
|
||||||
const Balance = require('./Balance');
|
|
||||||
const User = require('./User');
|
|
||||||
const Key = require('./Key');
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
...methods,
|
||||||
comparePassword,
|
comparePassword,
|
||||||
deleteUserById,
|
|
||||||
generateToken,
|
|
||||||
getUserById,
|
|
||||||
updateUser,
|
|
||||||
createUser,
|
|
||||||
countUsers,
|
|
||||||
findUser,
|
|
||||||
|
|
||||||
findFileById,
|
findFileById,
|
||||||
createFile,
|
createFile,
|
||||||
updateFile,
|
updateFile,
|
||||||
@@ -77,21 +51,4 @@ module.exports = {
|
|||||||
getPresets,
|
getPresets,
|
||||||
savePreset,
|
savePreset,
|
||||||
deletePresets,
|
deletePresets,
|
||||||
|
|
||||||
createToken,
|
|
||||||
findToken,
|
|
||||||
updateToken,
|
|
||||||
deleteTokens,
|
|
||||||
|
|
||||||
createSession,
|
|
||||||
findSession,
|
|
||||||
updateExpiration,
|
|
||||||
deleteSession,
|
|
||||||
deleteAllUserSessions,
|
|
||||||
generateRefreshToken,
|
|
||||||
countActiveSessions,
|
|
||||||
|
|
||||||
User,
|
|
||||||
Key,
|
|
||||||
Balance,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const { getRandomValues, hashToken } = require('~/server/utils/crypto');
|
const { logger, hashToken } = require('@librechat/data-schemas');
|
||||||
const { createToken, findToken } = require('./Token');
|
const { getRandomValues } = require('~/server/utils/crypto');
|
||||||
const logger = require('~/config/winston');
|
const { createToken, findToken } = require('~/models');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @module inviteUser
|
* @module inviteUser
|
||||||
|
|||||||
@@ -1,475 +0,0 @@
|
|||||||
const _ = require('lodash');
|
|
||||||
const mongoose = require('mongoose');
|
|
||||||
const { MeiliSearch } = require('meilisearch');
|
|
||||||
const { parseTextParts, ContentTypes } = require('librechat-data-provider');
|
|
||||||
const { cleanUpPrimaryKeyValue } = require('~/lib/utils/misc');
|
|
||||||
const logger = require('~/config/meiliLogger');
|
|
||||||
|
|
||||||
// Environment flags
|
|
||||||
/**
|
|
||||||
* Flag to indicate if search is enabled based on environment variables.
|
|
||||||
* @type {boolean}
|
|
||||||
*/
|
|
||||||
const searchEnabled = process.env.SEARCH && process.env.SEARCH.toLowerCase() === 'true';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flag to indicate if MeiliSearch is enabled based on required environment variables.
|
|
||||||
* @type {boolean}
|
|
||||||
*/
|
|
||||||
const meiliEnabled = process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY && searchEnabled;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the required options for configuring the mongoMeili plugin.
|
|
||||||
*
|
|
||||||
* @param {Object} options - The configuration options.
|
|
||||||
* @param {string} options.host - The MeiliSearch host.
|
|
||||||
* @param {string} options.apiKey - The MeiliSearch API key.
|
|
||||||
* @param {string} options.indexName - The name of the index.
|
|
||||||
* @throws {Error} Throws an error if any required option is missing.
|
|
||||||
*/
|
|
||||||
const validateOptions = function (options) {
|
|
||||||
const requiredKeys = ['host', 'apiKey', 'indexName'];
|
|
||||||
requiredKeys.forEach((key) => {
|
|
||||||
if (!options[key]) {
|
|
||||||
throw new Error(`Missing mongoMeili Option: ${key}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory function to create a MeiliMongooseModel class which extends a Mongoose model.
|
|
||||||
* This class contains static and instance methods to synchronize and manage the MeiliSearch index
|
|
||||||
* corresponding to the MongoDB collection.
|
|
||||||
*
|
|
||||||
* @param {Object} config - Configuration object.
|
|
||||||
* @param {Object} config.index - The MeiliSearch index object.
|
|
||||||
* @param {Array<string>} config.attributesToIndex - List of attributes to index.
|
|
||||||
* @returns {Function} A class definition that will be loaded into the Mongoose schema.
|
|
||||||
*/
|
|
||||||
const createMeiliMongooseModel = function ({ index, attributesToIndex }) {
|
|
||||||
// The primary key is assumed to be the first attribute in the attributesToIndex array.
|
|
||||||
const primaryKey = attributesToIndex[0];
|
|
||||||
|
|
||||||
class MeiliMongooseModel {
|
|
||||||
/**
|
|
||||||
* Synchronizes the data between the MongoDB collection and the MeiliSearch index.
|
|
||||||
*
|
|
||||||
* The synchronization process involves:
|
|
||||||
* 1. Fetching all documents from the MongoDB collection and MeiliSearch index.
|
|
||||||
* 2. Comparing documents from both sources.
|
|
||||||
* 3. Deleting documents from MeiliSearch that no longer exist in MongoDB.
|
|
||||||
* 4. Adding documents to MeiliSearch that exist in MongoDB but not in the index.
|
|
||||||
* 5. Updating documents in MeiliSearch if key fields (such as `text` or `title`) differ.
|
|
||||||
* 6. Updating the `_meiliIndex` field in MongoDB to indicate the indexing status.
|
|
||||||
*
|
|
||||||
* Note: The function processes documents in batches because MeiliSearch's
|
|
||||||
* `index.getDocuments` requires an exact limit and `index.addDocuments` does not handle
|
|
||||||
* partial failures in a batch.
|
|
||||||
*
|
|
||||||
* @returns {Promise<void>} Resolves when the synchronization is complete.
|
|
||||||
*/
|
|
||||||
static async syncWithMeili() {
|
|
||||||
try {
|
|
||||||
let moreDocuments = true;
|
|
||||||
// Retrieve all MongoDB documents from the collection as plain JavaScript objects.
|
|
||||||
const mongoDocuments = await this.find().lean();
|
|
||||||
|
|
||||||
// Helper function to format a document by selecting only the attributes to index
|
|
||||||
// and omitting keys starting with '$'.
|
|
||||||
const format = (doc) =>
|
|
||||||
_.omitBy(_.pick(doc, attributesToIndex), (v, k) => k.startsWith('$'));
|
|
||||||
|
|
||||||
// Build a map of MongoDB documents for quick lookup based on the primary key.
|
|
||||||
const mongoMap = new Map(mongoDocuments.map((doc) => [doc[primaryKey], format(doc)]));
|
|
||||||
const indexMap = new Map();
|
|
||||||
let offset = 0;
|
|
||||||
const batchSize = 1000;
|
|
||||||
|
|
||||||
// Fetch documents from the MeiliSearch index in batches.
|
|
||||||
while (moreDocuments) {
|
|
||||||
const batch = await index.getDocuments({ limit: batchSize, offset });
|
|
||||||
if (batch.results.length === 0) {
|
|
||||||
moreDocuments = false;
|
|
||||||
}
|
|
||||||
for (const doc of batch.results) {
|
|
||||||
indexMap.set(doc[primaryKey], format(doc));
|
|
||||||
}
|
|
||||||
offset += batchSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('[syncWithMeili]', { indexMap: indexMap.size, mongoMap: mongoMap.size });
|
|
||||||
|
|
||||||
const updateOps = [];
|
|
||||||
|
|
||||||
// Process documents present in the MeiliSearch index.
|
|
||||||
for (const [id, doc] of indexMap) {
|
|
||||||
const update = {};
|
|
||||||
update[primaryKey] = id;
|
|
||||||
if (mongoMap.has(id)) {
|
|
||||||
// If document exists in MongoDB, check for discrepancies in key fields.
|
|
||||||
if (
|
|
||||||
(doc.text && doc.text !== mongoMap.get(id).text) ||
|
|
||||||
(doc.title && doc.title !== mongoMap.get(id).title)
|
|
||||||
) {
|
|
||||||
logger.debug(
|
|
||||||
`[syncWithMeili] ${id} had document discrepancy in ${
|
|
||||||
doc.text ? 'text' : 'title'
|
|
||||||
} field`,
|
|
||||||
);
|
|
||||||
updateOps.push({
|
|
||||||
updateOne: { filter: update, update: { $set: { _meiliIndex: true } } },
|
|
||||||
});
|
|
||||||
await index.addDocuments([doc]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If the document does not exist in MongoDB, delete it from MeiliSearch.
|
|
||||||
await index.deleteDocument(id);
|
|
||||||
updateOps.push({
|
|
||||||
updateOne: { filter: update, update: { $set: { _meiliIndex: false } } },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process documents present in MongoDB.
|
|
||||||
for (const [id, doc] of mongoMap) {
|
|
||||||
const update = {};
|
|
||||||
update[primaryKey] = id;
|
|
||||||
// If the document is missing in the Meili index, add it.
|
|
||||||
if (!indexMap.has(id)) {
|
|
||||||
await index.addDocuments([doc]);
|
|
||||||
updateOps.push({
|
|
||||||
updateOne: { filter: update, update: { $set: { _meiliIndex: true } } },
|
|
||||||
});
|
|
||||||
} else if (doc._meiliIndex === false) {
|
|
||||||
// If the document exists but is marked as not indexed, update the flag.
|
|
||||||
updateOps.push({
|
|
||||||
updateOne: { filter: update, update: { $set: { _meiliIndex: true } } },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute bulk update operations in MongoDB to update the _meiliIndex flags.
|
|
||||||
if (updateOps.length > 0) {
|
|
||||||
await this.collection.bulkWrite(updateOps);
|
|
||||||
logger.debug(
|
|
||||||
`[syncWithMeili] Finished indexing ${
|
|
||||||
primaryKey === 'messageId' ? 'messages' : 'conversations'
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[syncWithMeili] Error adding document to Meili', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates settings for the MeiliSearch index.
|
|
||||||
*
|
|
||||||
* @param {Object} settings - The settings to update on the MeiliSearch index.
|
|
||||||
* @returns {Promise<Object>} Promise resolving to the update result.
|
|
||||||
*/
|
|
||||||
static async setMeiliIndexSettings(settings) {
|
|
||||||
return await index.updateSettings(settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Searches the MeiliSearch index and optionally populates the results with data from MongoDB.
|
|
||||||
*
|
|
||||||
* @param {string} q - The search query.
|
|
||||||
* @param {Object} params - Additional search parameters for MeiliSearch.
|
|
||||||
* @param {boolean} populate - Whether to populate search hits with full MongoDB documents.
|
|
||||||
* @returns {Promise<Object>} The search results with populated hits if requested.
|
|
||||||
*/
|
|
||||||
static async meiliSearch(q, params, populate) {
|
|
||||||
const data = await index.search(q, params);
|
|
||||||
|
|
||||||
if (populate) {
|
|
||||||
// Build a query using the primary key values from the search hits.
|
|
||||||
const query = {};
|
|
||||||
query[primaryKey] = _.map(data.hits, (hit) => cleanUpPrimaryKeyValue(hit[primaryKey]));
|
|
||||||
|
|
||||||
// Build a projection object, including only keys that do not start with '$'.
|
|
||||||
const projection = Object.keys(this.schema.obj).reduce(
|
|
||||||
(results, key) => {
|
|
||||||
if (!key.startsWith('$')) {
|
|
||||||
results[key] = 1;
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
},
|
|
||||||
{ _id: 1, __v: 1 },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Retrieve the full documents from MongoDB.
|
|
||||||
const hitsFromMongoose = await this.find(query, projection).lean();
|
|
||||||
|
|
||||||
// Merge the MongoDB documents with the search hits.
|
|
||||||
const populatedHits = data.hits.map(function (hit) {
|
|
||||||
const query = {};
|
|
||||||
query[primaryKey] = hit[primaryKey];
|
|
||||||
const originalHit = _.find(hitsFromMongoose, query);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...(originalHit ?? {}),
|
|
||||||
...hit,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
data.hits = populatedHits;
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preprocesses the current document for indexing.
|
|
||||||
*
|
|
||||||
* This method:
|
|
||||||
* - Picks only the defined attributes to index.
|
|
||||||
* - Omits any keys starting with '$'.
|
|
||||||
* - Replaces pipe characters ('|') in `conversationId` with '--'.
|
|
||||||
* - Extracts and concatenates text from an array of content items.
|
|
||||||
*
|
|
||||||
* @returns {Object} The preprocessed object ready for indexing.
|
|
||||||
*/
|
|
||||||
preprocessObjectForIndex() {
|
|
||||||
const object = _.omitBy(_.pick(this.toJSON(), attributesToIndex), (v, k) =>
|
|
||||||
k.startsWith('$'),
|
|
||||||
);
|
|
||||||
if (object.conversationId && object.conversationId.includes('|')) {
|
|
||||||
object.conversationId = object.conversationId.replace(/\|/g, '--');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (object.content && Array.isArray(object.content)) {
|
|
||||||
object.text = parseTextParts(object.content);
|
|
||||||
delete object.content;
|
|
||||||
}
|
|
||||||
|
|
||||||
return object;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds the current document to the MeiliSearch index.
|
|
||||||
*
|
|
||||||
* The method preprocesses the document, adds it to MeiliSearch, and then updates
|
|
||||||
* the MongoDB document's `_meiliIndex` flag to true.
|
|
||||||
*
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async addObjectToMeili() {
|
|
||||||
const object = this.preprocessObjectForIndex();
|
|
||||||
try {
|
|
||||||
await index.addDocuments([object]);
|
|
||||||
} catch (error) {
|
|
||||||
// Error handling can be enhanced as needed.
|
|
||||||
logger.error('[addObjectToMeili] Error adding document to Meili', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.collection.updateMany({ _id: this._id }, { $set: { _meiliIndex: true } });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the current document in the MeiliSearch index.
|
|
||||||
*
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async updateObjectToMeili() {
|
|
||||||
const object = _.omitBy(_.pick(this.toJSON(), attributesToIndex), (v, k) =>
|
|
||||||
k.startsWith('$'),
|
|
||||||
);
|
|
||||||
await index.updateDocuments([object]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes the current document from the MeiliSearch index.
|
|
||||||
*
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async deleteObjectFromMeili() {
|
|
||||||
await index.deleteDocument(this._id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Post-save hook to synchronize the document with MeiliSearch.
|
|
||||||
*
|
|
||||||
* If the document is already indexed (i.e. `_meiliIndex` is true), it updates it;
|
|
||||||
* otherwise, it adds the document to the index.
|
|
||||||
*/
|
|
||||||
postSaveHook() {
|
|
||||||
if (this._meiliIndex) {
|
|
||||||
this.updateObjectToMeili();
|
|
||||||
} else {
|
|
||||||
this.addObjectToMeili();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Post-update hook to update the document in MeiliSearch.
|
|
||||||
*
|
|
||||||
* This hook is triggered after a document update, ensuring that changes are
|
|
||||||
* propagated to the MeiliSearch index if the document is indexed.
|
|
||||||
*/
|
|
||||||
postUpdateHook() {
|
|
||||||
if (this._meiliIndex) {
|
|
||||||
this.updateObjectToMeili();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Post-remove hook to delete the document from MeiliSearch.
|
|
||||||
*
|
|
||||||
* This hook is triggered after a document is removed, ensuring that the document
|
|
||||||
* is also removed from the MeiliSearch index if it was previously indexed.
|
|
||||||
*/
|
|
||||||
postRemoveHook() {
|
|
||||||
if (this._meiliIndex) {
|
|
||||||
this.deleteObjectFromMeili();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return MeiliMongooseModel;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mongoose plugin to synchronize MongoDB collections with a MeiliSearch index.
|
|
||||||
*
|
|
||||||
* This plugin:
|
|
||||||
* - Validates the provided options.
|
|
||||||
* - Adds a `_meiliIndex` field to the schema to track indexing status.
|
|
||||||
* - Sets up a MeiliSearch client and creates an index if it doesn't already exist.
|
|
||||||
* - Loads class methods for syncing, searching, and managing documents in MeiliSearch.
|
|
||||||
* - Registers Mongoose hooks (post-save, post-update, post-remove, etc.) to maintain index consistency.
|
|
||||||
*
|
|
||||||
* @param {mongoose.Schema} schema - The Mongoose schema to which the plugin is applied.
|
|
||||||
* @param {Object} options - Configuration options.
|
|
||||||
* @param {string} options.host - The MeiliSearch host.
|
|
||||||
* @param {string} options.apiKey - The MeiliSearch API key.
|
|
||||||
* @param {string} options.indexName - The name of the MeiliSearch index.
|
|
||||||
* @param {string} options.primaryKey - The primary key field for indexing.
|
|
||||||
*/
|
|
||||||
module.exports = function mongoMeili(schema, options) {
|
|
||||||
validateOptions(options);
|
|
||||||
|
|
||||||
// Add _meiliIndex field to the schema to track if a document has been indexed in MeiliSearch.
|
|
||||||
schema.add({
|
|
||||||
_meiliIndex: {
|
|
||||||
type: Boolean,
|
|
||||||
required: false,
|
|
||||||
select: false,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { host, apiKey, indexName, primaryKey } = options;
|
|
||||||
|
|
||||||
// Setup the MeiliSearch client.
|
|
||||||
const client = new MeiliSearch({ host, apiKey });
|
|
||||||
|
|
||||||
// Create the index asynchronously if it doesn't exist.
|
|
||||||
client.createIndex(indexName, { primaryKey });
|
|
||||||
|
|
||||||
// Setup the MeiliSearch index for this schema.
|
|
||||||
const index = client.index(indexName);
|
|
||||||
|
|
||||||
// Collect attributes from the schema that should be indexed.
|
|
||||||
const attributesToIndex = [
|
|
||||||
..._.reduce(
|
|
||||||
schema.obj,
|
|
||||||
function (results, value, key) {
|
|
||||||
return value.meiliIndex ? [...results, key] : results;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Load the class methods into the schema.
|
|
||||||
schema.loadClass(createMeiliMongooseModel({ index, indexName, client, attributesToIndex }));
|
|
||||||
|
|
||||||
// Register Mongoose hooks to synchronize with MeiliSearch.
|
|
||||||
|
|
||||||
// Post-save: synchronize after a document is saved.
|
|
||||||
schema.post('save', function (doc) {
|
|
||||||
doc.postSaveHook();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Post-update: synchronize after a document is updated.
|
|
||||||
schema.post('update', function (doc) {
|
|
||||||
doc.postUpdateHook();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Post-remove: synchronize after a document is removed.
|
|
||||||
schema.post('remove', function (doc) {
|
|
||||||
doc.postRemoveHook();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pre-deleteMany hook: remove corresponding documents from MeiliSearch when multiple documents are deleted.
|
|
||||||
schema.pre('deleteMany', async function (next) {
|
|
||||||
if (!meiliEnabled) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if the schema has a "messages" field to determine if it's a conversation schema.
|
|
||||||
if (Object.prototype.hasOwnProperty.call(schema.obj, 'messages')) {
|
|
||||||
const convoIndex = client.index('convos');
|
|
||||||
const deletedConvos = await mongoose.model('Conversation').find(this._conditions).lean();
|
|
||||||
const promises = deletedConvos.map((convo) =>
|
|
||||||
convoIndex.deleteDocument(convo.conversationId),
|
|
||||||
);
|
|
||||||
await Promise.all(promises);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the schema has a "messageId" field to determine if it's a message schema.
|
|
||||||
if (Object.prototype.hasOwnProperty.call(schema.obj, 'messageId')) {
|
|
||||||
const messageIndex = client.index('messages');
|
|
||||||
const deletedMessages = await mongoose.model('Message').find(this._conditions).lean();
|
|
||||||
const promises = deletedMessages.map((message) =>
|
|
||||||
messageIndex.deleteDocument(message.messageId),
|
|
||||||
);
|
|
||||||
await Promise.all(promises);
|
|
||||||
}
|
|
||||||
return next();
|
|
||||||
} catch (error) {
|
|
||||||
if (meiliEnabled) {
|
|
||||||
logger.error(
|
|
||||||
'[MeiliMongooseModel.deleteMany] There was an issue deleting conversation indexes upon deletion. Next startup may be slow due to syncing.',
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Post-findOneAndUpdate hook: update MeiliSearch index after a document is updated via findOneAndUpdate.
|
|
||||||
schema.post('findOneAndUpdate', async function (doc) {
|
|
||||||
if (!meiliEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the document is unfinished, do not update the index.
|
|
||||||
if (doc.unfinished) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let meiliDoc;
|
|
||||||
// For conversation documents, try to fetch the document from the "convos" index.
|
|
||||||
if (doc.messages) {
|
|
||||||
try {
|
|
||||||
meiliDoc = await client.index('convos').getDocument(doc.conversationId);
|
|
||||||
} catch (error) {
|
|
||||||
logger.debug(
|
|
||||||
'[MeiliMongooseModel.findOneAndUpdate] Convo not found in MeiliSearch and will index ' +
|
|
||||||
doc.conversationId,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the MeiliSearch document exists and the title is unchanged, do nothing.
|
|
||||||
if (meiliDoc && meiliDoc.title === doc.title) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, trigger a post-save hook to synchronize the document.
|
|
||||||
doc.postSaveHook();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
const mongoose = require('mongoose');
|
|
||||||
const mongoMeili = require('../plugins/mongoMeili');
|
|
||||||
|
|
||||||
const { convoSchema } = require('@librechat/data-schemas');
|
|
||||||
|
|
||||||
if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) {
|
|
||||||
convoSchema.plugin(mongoMeili, {
|
|
||||||
host: process.env.MEILI_HOST,
|
|
||||||
apiKey: process.env.MEILI_MASTER_KEY,
|
|
||||||
/** Note: Will get created automatically if it doesn't exist already */
|
|
||||||
indexName: 'convos',
|
|
||||||
primaryKey: 'conversationId',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const Conversation = mongoose.models.Conversation || mongoose.model('Conversation', convoSchema);
|
|
||||||
|
|
||||||
module.exports = Conversation;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
const mongoose = require('mongoose');
|
|
||||||
const mongoMeili = require('~/models/plugins/mongoMeili');
|
|
||||||
const { messageSchema } = require('@librechat/data-schemas');
|
|
||||||
|
|
||||||
if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) {
|
|
||||||
messageSchema.plugin(mongoMeili, {
|
|
||||||
host: process.env.MEILI_HOST,
|
|
||||||
apiKey: process.env.MEILI_MASTER_KEY,
|
|
||||||
indexName: 'messages',
|
|
||||||
primaryKey: 'messageId',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const Message = mongoose.models.Message || mongoose.model('Message', messageSchema);
|
|
||||||
|
|
||||||
module.exports = Message;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
const mongoose = require('mongoose');
|
|
||||||
const { pluginAuthSchema } = require('@librechat/data-schemas');
|
|
||||||
|
|
||||||
const PluginAuth = mongoose.models.Plugin || mongoose.model('PluginAuth', pluginAuthSchema);
|
|
||||||
|
|
||||||
module.exports = PluginAuth;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
const mongoose = require('mongoose');
|
|
||||||
const { presetSchema } = require('@librechat/data-schemas');
|
|
||||||
|
|
||||||
const Preset = mongoose.models.Preset || mongoose.model('Preset', presetSchema);
|
|
||||||
|
|
||||||
module.exports = Preset;
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
const { Transaction } = require('./Transaction');
|
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
const { createTransaction, createStructuredTransaction } = require('./Transaction');
|
||||||
/**
|
/**
|
||||||
* Creates up to two transactions to record the spending of tokens.
|
* Creates up to two transactions to record the spending of tokens.
|
||||||
*
|
*
|
||||||
@@ -33,7 +32,7 @@ const spendTokens = async (txData, tokenUsage) => {
|
|||||||
let prompt, completion;
|
let prompt, completion;
|
||||||
try {
|
try {
|
||||||
if (promptTokens !== undefined) {
|
if (promptTokens !== undefined) {
|
||||||
prompt = await Transaction.create({
|
prompt = await createTransaction({
|
||||||
...txData,
|
...txData,
|
||||||
tokenType: 'prompt',
|
tokenType: 'prompt',
|
||||||
rawAmount: promptTokens === 0 ? 0 : -Math.max(promptTokens, 0),
|
rawAmount: promptTokens === 0 ? 0 : -Math.max(promptTokens, 0),
|
||||||
@@ -41,7 +40,7 @@ const spendTokens = async (txData, tokenUsage) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (completionTokens !== undefined) {
|
if (completionTokens !== undefined) {
|
||||||
completion = await Transaction.create({
|
completion = await createTransaction({
|
||||||
...txData,
|
...txData,
|
||||||
tokenType: 'completion',
|
tokenType: 'completion',
|
||||||
rawAmount: completionTokens === 0 ? 0 : -Math.max(completionTokens, 0),
|
rawAmount: completionTokens === 0 ? 0 : -Math.max(completionTokens, 0),
|
||||||
@@ -101,7 +100,7 @@ const spendStructuredTokens = async (txData, tokenUsage) => {
|
|||||||
try {
|
try {
|
||||||
if (promptTokens) {
|
if (promptTokens) {
|
||||||
const { input = 0, write = 0, read = 0 } = promptTokens;
|
const { input = 0, write = 0, read = 0 } = promptTokens;
|
||||||
prompt = await Transaction.createStructured({
|
prompt = await createStructuredTransaction({
|
||||||
...txData,
|
...txData,
|
||||||
tokenType: 'prompt',
|
tokenType: 'prompt',
|
||||||
inputTokens: -input,
|
inputTokens: -input,
|
||||||
@@ -111,7 +110,7 @@ const spendStructuredTokens = async (txData, tokenUsage) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (completionTokens) {
|
if (completionTokens) {
|
||||||
completion = await Transaction.create({
|
completion = await createTransaction({
|
||||||
...txData,
|
...txData,
|
||||||
tokenType: 'completion',
|
tokenType: 'completion',
|
||||||
rawAmount: -completionTokens,
|
rawAmount: -completionTokens,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
const { Transaction } = require('./Transaction');
|
|
||||||
const Balance = require('./Balance');
|
|
||||||
const { spendTokens, spendStructuredTokens } = require('./spendTokens');
|
const { spendTokens, spendStructuredTokens } = require('./spendTokens');
|
||||||
|
const { createTransaction, createAutoRefillTransaction } = require('./Transaction');
|
||||||
|
|
||||||
|
require('~/db/models');
|
||||||
|
|
||||||
// Mock the logger to prevent console output during tests
|
// Mock the logger to prevent console output during tests
|
||||||
jest.mock('~/config', () => ({
|
jest.mock('~/config', () => ({
|
||||||
@@ -19,11 +20,15 @@ jest.mock('~/server/services/Config');
|
|||||||
describe('spendTokens', () => {
|
describe('spendTokens', () => {
|
||||||
let mongoServer;
|
let mongoServer;
|
||||||
let userId;
|
let userId;
|
||||||
|
let Transaction;
|
||||||
|
let Balance;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
mongoServer = await MongoMemoryServer.create();
|
mongoServer = await MongoMemoryServer.create();
|
||||||
const mongoUri = mongoServer.getUri();
|
await mongoose.connect(mongoServer.getUri());
|
||||||
await mongoose.connect(mongoUri);
|
|
||||||
|
Transaction = mongoose.model('Transaction');
|
||||||
|
Balance = mongoose.model('Balance');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -197,7 +202,7 @@ describe('spendTokens', () => {
|
|||||||
// Check that the transaction records show the adjusted values
|
// Check that the transaction records show the adjusted values
|
||||||
const transactionResults = await Promise.all(
|
const transactionResults = await Promise.all(
|
||||||
transactions.map((t) =>
|
transactions.map((t) =>
|
||||||
Transaction.create({
|
createTransaction({
|
||||||
...txData,
|
...txData,
|
||||||
tokenType: t.tokenType,
|
tokenType: t.tokenType,
|
||||||
rawAmount: t.rawAmount,
|
rawAmount: t.rawAmount,
|
||||||
@@ -280,7 +285,7 @@ describe('spendTokens', () => {
|
|||||||
|
|
||||||
// Check the return values from Transaction.create directly
|
// Check the return values from Transaction.create directly
|
||||||
// This is to verify that the incrementValue is not becoming positive
|
// This is to verify that the incrementValue is not becoming positive
|
||||||
const directResult = await Transaction.create({
|
const directResult = await createTransaction({
|
||||||
user: userId,
|
user: userId,
|
||||||
conversationId: 'test-convo-3',
|
conversationId: 'test-convo-3',
|
||||||
model: 'gpt-4',
|
model: 'gpt-4',
|
||||||
@@ -607,7 +612,7 @@ describe('spendTokens', () => {
|
|||||||
const promises = [];
|
const promises = [];
|
||||||
for (let i = 0; i < numberOfRefills; i++) {
|
for (let i = 0; i < numberOfRefills; i++) {
|
||||||
promises.push(
|
promises.push(
|
||||||
Transaction.createAutoRefillTransaction({
|
createAutoRefillTransaction({
|
||||||
user: userId,
|
user: userId,
|
||||||
tokenType: 'credits',
|
tokenType: 'credits',
|
||||||
context: 'concurrent-refill-test',
|
context: 'concurrent-refill-test',
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ const tokenValues = Object.assign(
|
|||||||
'claude-3-5-haiku': { prompt: 0.8, completion: 4 },
|
'claude-3-5-haiku': { prompt: 0.8, completion: 4 },
|
||||||
'claude-3.5-haiku': { prompt: 0.8, completion: 4 },
|
'claude-3.5-haiku': { prompt: 0.8, completion: 4 },
|
||||||
'claude-3-haiku': { prompt: 0.25, completion: 1.25 },
|
'claude-3-haiku': { prompt: 0.25, completion: 1.25 },
|
||||||
|
'claude-sonnet-4': { prompt: 3, completion: 15 },
|
||||||
|
'claude-opus-4': { prompt: 15, completion: 75 },
|
||||||
'claude-2.1': { prompt: 8, completion: 24 },
|
'claude-2.1': { prompt: 8, completion: 24 },
|
||||||
'claude-2': { prompt: 8, completion: 24 },
|
'claude-2': { prompt: 8, completion: 24 },
|
||||||
'claude-instant': { prompt: 0.8, completion: 2.4 },
|
'claude-instant': { prompt: 0.8, completion: 2.4 },
|
||||||
@@ -162,6 +164,8 @@ const cacheTokenValues = {
|
|||||||
'claude-3.5-haiku': { write: 1, read: 0.08 },
|
'claude-3.5-haiku': { write: 1, read: 0.08 },
|
||||||
'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-3-haiku': { write: 0.3, read: 0.03 },
|
||||||
|
'claude-sonnet-4': { write: 3.75, read: 0.3 },
|
||||||
|
'claude-opus-4': { write: 18.75, read: 1.5 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -664,3 +664,97 @@ describe('Grok Model Tests - Pricing', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 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 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,159 +1,4 @@
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const { getBalanceConfig } = require('~/server/services/Config');
|
|
||||||
const signPayload = require('~/server/services/signPayload');
|
|
||||||
const Balance = require('./Balance');
|
|
||||||
const User = require('./User');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve a user by ID and convert the found user document to a plain object.
|
|
||||||
*
|
|
||||||
* @param {string} userId - The ID of the user to find and return as a plain object.
|
|
||||||
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
|
|
||||||
* @returns {Promise<MongoUser>} A plain object representing the user document, or `null` if no user is found.
|
|
||||||
*/
|
|
||||||
const getUserById = async function (userId, fieldsToSelect = null) {
|
|
||||||
const query = User.findById(userId);
|
|
||||||
if (fieldsToSelect) {
|
|
||||||
query.select(fieldsToSelect);
|
|
||||||
}
|
|
||||||
return await query.lean();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search for a single user based on partial data and return matching user document as plain object.
|
|
||||||
* @param {Partial<MongoUser>} searchCriteria - The partial data to use for searching the user.
|
|
||||||
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
|
|
||||||
* @returns {Promise<MongoUser>} A plain object representing the user document, or `null` if no user is found.
|
|
||||||
*/
|
|
||||||
const findUser = async function (searchCriteria, fieldsToSelect = null) {
|
|
||||||
const query = User.findOne(searchCriteria);
|
|
||||||
if (fieldsToSelect) {
|
|
||||||
query.select(fieldsToSelect);
|
|
||||||
}
|
|
||||||
return await query.lean();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a user with new data without overwriting existing properties.
|
|
||||||
*
|
|
||||||
* @param {string} userId - The ID of the user to update.
|
|
||||||
* @param {Object} updateData - An object containing the properties to update.
|
|
||||||
* @returns {Promise<MongoUser>} The updated user document as a plain object, or `null` if no user is found.
|
|
||||||
*/
|
|
||||||
const updateUser = async function (userId, updateData) {
|
|
||||||
const updateOperation = {
|
|
||||||
$set: updateData,
|
|
||||||
$unset: { expiresAt: '' }, // Remove the expiresAt field to prevent TTL
|
|
||||||
};
|
|
||||||
return await User.findByIdAndUpdate(userId, updateOperation, {
|
|
||||||
new: true,
|
|
||||||
runValidators: true,
|
|
||||||
}).lean();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new user, optionally with a TTL of 1 week.
|
|
||||||
* @param {MongoUser} data - The user data to be created, must contain user_id.
|
|
||||||
* @param {boolean} [disableTTL=true] - Whether to disable the TTL. Defaults to `true`.
|
|
||||||
* @param {boolean} [returnUser=false] - Whether to return the created user object.
|
|
||||||
* @returns {Promise<ObjectId|MongoUser>} A promise that resolves to the created user document ID or user object.
|
|
||||||
* @throws {Error} If a user with the same user_id already exists.
|
|
||||||
*/
|
|
||||||
const createUser = async (data, disableTTL = true, returnUser = false) => {
|
|
||||||
const balance = await getBalanceConfig();
|
|
||||||
const userData = {
|
|
||||||
...data,
|
|
||||||
expiresAt: disableTTL ? null : new Date(Date.now() + 604800 * 1000), // 1 week in milliseconds
|
|
||||||
};
|
|
||||||
|
|
||||||
if (disableTTL) {
|
|
||||||
delete userData.expiresAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await User.create(userData);
|
|
||||||
|
|
||||||
// If balance is enabled, create or update a balance record for the user using global.interfaceConfig.balance
|
|
||||||
if (balance?.enabled && balance?.startBalance) {
|
|
||||||
const update = {
|
|
||||||
$inc: { tokenCredits: balance.startBalance },
|
|
||||||
};
|
|
||||||
|
|
||||||
if (
|
|
||||||
balance.autoRefillEnabled &&
|
|
||||||
balance.refillIntervalValue != null &&
|
|
||||||
balance.refillIntervalUnit != null &&
|
|
||||||
balance.refillAmount != null
|
|
||||||
) {
|
|
||||||
update.$set = {
|
|
||||||
autoRefillEnabled: true,
|
|
||||||
refillIntervalValue: balance.refillIntervalValue,
|
|
||||||
refillIntervalUnit: balance.refillIntervalUnit,
|
|
||||||
refillAmount: balance.refillAmount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await Balance.findOneAndUpdate({ user: user._id }, update, { upsert: true, new: true }).lean();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (returnUser) {
|
|
||||||
return user.toObject();
|
|
||||||
}
|
|
||||||
return user._id;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count the number of user documents in the collection based on the provided filter.
|
|
||||||
*
|
|
||||||
* @param {Object} [filter={}] - The filter to apply when counting the documents.
|
|
||||||
* @returns {Promise<number>} The count of documents that match the filter.
|
|
||||||
*/
|
|
||||||
const countUsers = async function (filter = {}) {
|
|
||||||
return await User.countDocuments(filter);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a user by their unique ID.
|
|
||||||
*
|
|
||||||
* @param {string} userId - The ID of the user to delete.
|
|
||||||
* @returns {Promise<{ deletedCount: number }>} An object indicating the number of deleted documents.
|
|
||||||
*/
|
|
||||||
const deleteUserById = async function (userId) {
|
|
||||||
try {
|
|
||||||
const result = await User.deleteOne({ _id: userId });
|
|
||||||
if (result.deletedCount === 0) {
|
|
||||||
return { deletedCount: 0, message: 'No user found with that ID.' };
|
|
||||||
}
|
|
||||||
return { deletedCount: result.deletedCount, message: 'User was deleted successfully.' };
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error('Error deleting user: ' + error.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const { SESSION_EXPIRY } = process.env ?? {};
|
|
||||||
const expires = eval(SESSION_EXPIRY) ?? 1000 * 60 * 15;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a JWT token for a given user.
|
|
||||||
*
|
|
||||||
* @param {MongoUser} user - The user for whom the token is being generated.
|
|
||||||
* @returns {Promise<string>} A promise that resolves to a JWT token.
|
|
||||||
*/
|
|
||||||
const generateToken = async (user) => {
|
|
||||||
if (!user) {
|
|
||||||
throw new Error('No user provided');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await signPayload({
|
|
||||||
payload: {
|
|
||||||
id: user._id,
|
|
||||||
username: user.username,
|
|
||||||
provider: user.provider,
|
|
||||||
email: user.email,
|
|
||||||
},
|
|
||||||
secret: process.env.JWT_SECRET,
|
|
||||||
expirationTime: expires / 1000,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compares the provided password with the user's password.
|
* Compares the provided password with the user's password.
|
||||||
@@ -167,6 +12,10 @@ const comparePassword = async (user, candidatePassword) => {
|
|||||||
throw new Error('No user provided');
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
bcrypt.compare(candidatePassword, user.password, (err, isMatch) => {
|
bcrypt.compare(candidatePassword, user.password, (err, isMatch) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -179,11 +28,4 @@ const comparePassword = async (user, candidatePassword) => {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
comparePassword,
|
comparePassword,
|
||||||
deleteUserById,
|
|
||||||
generateToken,
|
|
||||||
getUserById,
|
|
||||||
countUsers,
|
|
||||||
createUser,
|
|
||||||
updateUser,
|
|
||||||
findUser,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,13 +43,16 @@
|
|||||||
"@google/generative-ai": "^0.23.0",
|
"@google/generative-ai": "^0.23.0",
|
||||||
"@googleapis/youtube": "^20.0.0",
|
"@googleapis/youtube": "^20.0.0",
|
||||||
"@keyv/redis": "^4.3.3",
|
"@keyv/redis": "^4.3.3",
|
||||||
"@langchain/community": "^0.3.42",
|
"@langchain/community": "^0.3.44",
|
||||||
"@langchain/core": "^0.3.55",
|
"@langchain/core": "^0.3.57",
|
||||||
"@langchain/google-genai": "^0.2.8",
|
"@langchain/google-genai": "^0.2.9",
|
||||||
"@langchain/google-vertexai": "^0.2.8",
|
"@langchain/google-vertexai": "^0.2.9",
|
||||||
"@langchain/textsplitters": "^0.1.0",
|
"@langchain/textsplitters": "^0.1.0",
|
||||||
"@librechat/agents": "^2.4.317",
|
"@librechat/agents": "^2.4.38",
|
||||||
|
"@librechat/auth": "*",
|
||||||
|
"@librechat/api": "*",
|
||||||
"@librechat/data-schemas": "*",
|
"@librechat/data-schemas": "*",
|
||||||
|
"@node-saml/passport-saml": "^5.0.0",
|
||||||
"@waylaidwanderer/fetch-event-source": "^3.0.1",
|
"@waylaidwanderer/fetch-event-source": "^3.0.1",
|
||||||
"axios": "^1.8.2",
|
"axios": "^1.8.2",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
@@ -75,24 +78,25 @@
|
|||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"jsonwebtoken": "^9.0.0",
|
"jsonwebtoken": "^9.0.0",
|
||||||
|
"jwks-rsa": "^3.2.0",
|
||||||
"keyv": "^5.3.2",
|
"keyv": "^5.3.2",
|
||||||
"keyv-file": "^5.1.2",
|
"keyv-file": "^5.1.2",
|
||||||
"klona": "^2.0.6",
|
"klona": "^2.0.6",
|
||||||
"librechat-data-provider": "*",
|
"librechat-data-provider": "*",
|
||||||
"librechat-mcp": "*",
|
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"meilisearch": "^0.38.0",
|
"meilisearch": "^0.38.0",
|
||||||
"memorystore": "^1.6.7",
|
"memorystore": "^1.6.7",
|
||||||
"mime": "^3.0.0",
|
"mime": "^3.0.0",
|
||||||
"module-alias": "^2.2.3",
|
"module-alias": "^2.2.3",
|
||||||
"mongoose": "^8.12.1",
|
"mongoose": "^8.12.1",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^2.0.1",
|
||||||
"nanoid": "^3.3.7",
|
"nanoid": "^3.3.7",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
"nodemailer": "^6.9.15",
|
"nodemailer": "^6.9.15",
|
||||||
"ollama": "^0.5.0",
|
"ollama": "^0.5.0",
|
||||||
"openai": "^4.96.2",
|
"openai": "^4.96.2",
|
||||||
"openai-chat-tokens": "^0.2.8",
|
"openai-chat-tokens": "^0.2.8",
|
||||||
"openid-client": "^5.4.2",
|
"openid-client": "^6.5.0",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
"passport-apple": "^2.0.2",
|
"passport-apple": "^2.0.2",
|
||||||
"passport-discord": "^0.1.4",
|
"passport-discord": "^0.1.4",
|
||||||
@@ -107,8 +111,9 @@
|
|||||||
"tiktoken": "^1.0.15",
|
"tiktoken": "^1.0.15",
|
||||||
"traverse": "^0.6.7",
|
"traverse": "^0.6.7",
|
||||||
"ua-parser-js": "^1.0.36",
|
"ua-parser-js": "^1.0.36",
|
||||||
|
"undici": "^7.10.0",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
"winston-daily-rotate-file": "^4.7.1",
|
"winston-daily-rotate-file": "^5.0.0",
|
||||||
"youtube-transcript": "^1.2.1",
|
"youtube-transcript": "^1.2.1",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
@@ -116,6 +121,7 @@
|
|||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"mongodb-memory-server": "^10.1.3",
|
"mongodb-memory-server": "^10.1.3",
|
||||||
"nodemon": "^3.0.3",
|
"nodemon": "^3.0.3",
|
||||||
"supertest": "^7.1.0"
|
"supertest": "^7.1.0",
|
||||||
|
"ts-jest": "^29.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,17 +16,17 @@ const FinalizationRegistry = global.FinalizationRegistry || null;
|
|||||||
*/
|
*/
|
||||||
const clientRegistry = FinalizationRegistry
|
const clientRegistry = FinalizationRegistry
|
||||||
? new FinalizationRegistry((heldValue) => {
|
? new FinalizationRegistry((heldValue) => {
|
||||||
try {
|
try {
|
||||||
// This will run when the client is garbage collected
|
// This will run when the client is garbage collected
|
||||||
if (heldValue && heldValue.userId) {
|
if (heldValue && heldValue.userId) {
|
||||||
logger.debug(`[FinalizationRegistry] Cleaning up client for user ${heldValue.userId}`);
|
logger.debug(`[FinalizationRegistry] Cleaning up client for user ${heldValue.userId}`);
|
||||||
} else {
|
} else {
|
||||||
logger.debug('[FinalizationRegistry] Cleaning up client');
|
logger.debug('[FinalizationRegistry] Cleaning up client');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors
|
||||||
}
|
}
|
||||||
} catch (e) {
|
})
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
})
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,15 +134,12 @@ function disposeClient(client) {
|
|||||||
if (client.message_delta) {
|
if (client.message_delta) {
|
||||||
client.message_delta = null;
|
client.message_delta = null;
|
||||||
}
|
}
|
||||||
if (client.isClaude3 !== undefined) {
|
if (client.isClaudeLatest !== undefined) {
|
||||||
client.isClaude3 = null;
|
client.isClaudeLatest = null;
|
||||||
}
|
}
|
||||||
if (client.useMessages !== undefined) {
|
if (client.useMessages !== undefined) {
|
||||||
client.useMessages = null;
|
client.useMessages = null;
|
||||||
}
|
}
|
||||||
if (client.isLegacyOutput !== undefined) {
|
|
||||||
client.isLegacyOutput = null;
|
|
||||||
}
|
|
||||||
if (client.supportsCacheControl !== undefined) {
|
if (client.supportsCacheControl !== undefined) {
|
||||||
client.supportsCacheControl = null;
|
client.supportsCacheControl = null;
|
||||||
}
|
}
|
||||||
@@ -223,6 +220,9 @@ function disposeClient(client) {
|
|||||||
if (client.maxResponseTokens) {
|
if (client.maxResponseTokens) {
|
||||||
client.maxResponseTokens = null;
|
client.maxResponseTokens = null;
|
||||||
}
|
}
|
||||||
|
if (client.processMemory) {
|
||||||
|
client.processMemory = null;
|
||||||
|
}
|
||||||
if (client.run) {
|
if (client.run) {
|
||||||
// Break circular references in run
|
// Break circular references in run
|
||||||
if (client.run.Graph) {
|
if (client.run.Graph) {
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
const cookies = require('cookie');
|
const cookies = require('cookie');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const {
|
const {
|
||||||
registerUser,
|
registerUser,
|
||||||
|
requestPasswordReset,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
setAuthTokens,
|
setAuthTokens,
|
||||||
requestPasswordReset,
|
setOpenIDAuthTokens,
|
||||||
} = require('~/server/services/AuthService');
|
} = require('@librechat/auth');
|
||||||
const { findSession, getUserById, deleteAllUserSessions } = require('~/models');
|
const { findUser, getUserById, deleteAllUserSessions, findSession } = require('~/models');
|
||||||
const { logger } = require('~/config');
|
const { getOpenIdConfig } = require('@librechat/auth');
|
||||||
|
const { isEnabled } = require('~/server/utils');
|
||||||
|
const { isEmailDomainAllowed } = require('~/server/services/domains');
|
||||||
|
const { getBalanceConfig } = require('~/server/services/Config');
|
||||||
|
|
||||||
const registrationController = async (req, res) => {
|
const registrationController = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const response = await registerUser(req.body);
|
const isEmailDomAllowed = await isEmailDomainAllowed(req.body.email);
|
||||||
|
const balanceConfig = await getBalanceConfig();
|
||||||
|
const response = await registerUser(req.body, {}, isEmailDomAllowed, balanceConfig);
|
||||||
const { status, message } = response;
|
const { status, message } = response;
|
||||||
res.status(status).send({ message });
|
res.status(status).send({ message });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -55,10 +62,30 @@ const resetPasswordController = async (req, res) => {
|
|||||||
|
|
||||||
const refreshController = async (req, res) => {
|
const refreshController = async (req, res) => {
|
||||||
const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null;
|
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) {
|
if (!refreshToken) {
|
||||||
return res.status(200).send('Refresh token not provided');
|
return res.status(200).send('Refresh token not provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (token_provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS) === true) {
|
||||||
|
try {
|
||||||
|
const openIdConfig = getOpenIdConfig();
|
||||||
|
const openIdClient = await import('openid-client');
|
||||||
|
const tokenset = await openIdClient.refreshTokenGrant(openIdConfig, refreshToken);
|
||||||
|
const claims = tokenset.claims();
|
||||||
|
const user = await findUser({ email: claims.email });
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).redirect('/login');
|
||||||
|
}
|
||||||
|
const token = setOpenIDAuthTokens(tokenset, res);
|
||||||
|
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 {
|
try {
|
||||||
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
|
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
|
||||||
const user = await getUserById(payload.id, '-password -__v -totpSecret');
|
const user = await getUserById(payload.id, '-password -__v -totpSecret');
|
||||||
@@ -74,7 +101,10 @@ const refreshController = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find the session with the hashed refresh token
|
// Find the session with the hashed refresh token
|
||||||
const session = await findSession({ userId: userId, refreshToken: refreshToken });
|
const session = await findSession({
|
||||||
|
userId: userId,
|
||||||
|
refreshToken: refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
if (session && session.expiration > new Date()) {
|
if (session && session.expiration > new Date()) {
|
||||||
const token = await setAuthTokens(userId, res, session._id);
|
const token = await setAuthTokens(userId, res, session._id);
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
const Balance = require('~/models/Balance');
|
const { Balance } = require('~/db/models');
|
||||||
|
|
||||||
async function balanceController(req, res) {
|
async function balanceController(req, res) {
|
||||||
const { tokenCredits: balance = '' } =
|
const balanceData = await Balance.findOne(
|
||||||
(await Balance.findOne({ user: req.user.id }, 'tokenCredits').lean()) ?? {};
|
{ user: req.user.id },
|
||||||
res.status(200).send('' + balance);
|
'-_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;
|
module.exports = balanceController;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const {
|
const {
|
||||||
|
verifyTOTP,
|
||||||
|
getTOTPSecret,
|
||||||
|
verifyBackupCode,
|
||||||
generateTOTPSecret,
|
generateTOTPSecret,
|
||||||
generateBackupCodes,
|
generateBackupCodes,
|
||||||
verifyTOTP,
|
|
||||||
verifyBackupCode,
|
|
||||||
getTOTPSecret,
|
|
||||||
} = require('~/server/services/twoFactorService');
|
} = require('~/server/services/twoFactorService');
|
||||||
const { updateUser, getUserById } = require('~/models');
|
const { getUserById, updateUser } = require('~/models');
|
||||||
const { logger } = require('~/config');
|
|
||||||
const { encryptV3 } = require('~/server/utils/crypto');
|
const { encryptV3 } = require('~/server/utils/crypto');
|
||||||
|
|
||||||
const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, '');
|
const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, '');
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
const { FileSources } = require('librechat-data-provider');
|
|
||||||
const {
|
const {
|
||||||
Balance,
|
Tools,
|
||||||
|
FileSources,
|
||||||
|
webSearchKeys,
|
||||||
|
extractWebSearchEnvVars,
|
||||||
|
} = require('librechat-data-provider');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const {
|
||||||
getFiles,
|
getFiles,
|
||||||
updateUser,
|
updateUser,
|
||||||
deleteFiles,
|
deleteFiles,
|
||||||
@@ -10,16 +15,14 @@ const {
|
|||||||
deleteUserById,
|
deleteUserById,
|
||||||
deleteAllUserSessions,
|
deleteAllUserSessions,
|
||||||
} = require('~/models');
|
} = require('~/models');
|
||||||
const User = require('~/models/User');
|
|
||||||
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
|
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
|
||||||
const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService');
|
const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService');
|
||||||
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
|
const { verifyEmail, resendVerificationEmail } = require('@librechat/auth');
|
||||||
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
|
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
|
||||||
const { processDeleteRequest } = require('~/server/services/Files/process');
|
const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||||
|
const { Transaction, Balance, User } = require('~/db/models');
|
||||||
const { deleteAllSharedLinks } = require('~/models/Share');
|
const { deleteAllSharedLinks } = require('~/models/Share');
|
||||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||||
const { Transaction } = require('~/models/Transaction');
|
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const getUserController = async (req, res) => {
|
const getUserController = async (req, res) => {
|
||||||
/** @type {MongoUser} */
|
/** @type {MongoUser} */
|
||||||
@@ -83,7 +86,6 @@ const deleteUserFiles = async (req) => {
|
|||||||
const updateUserPluginsController = async (req, res) => {
|
const updateUserPluginsController = async (req, res) => {
|
||||||
const { user } = req;
|
const { user } = req;
|
||||||
const { pluginKey, action, auth, isEntityTool } = req.body;
|
const { pluginKey, action, auth, isEntityTool } = req.body;
|
||||||
let authService;
|
|
||||||
try {
|
try {
|
||||||
if (!isEntityTool) {
|
if (!isEntityTool) {
|
||||||
const userPluginsService = await updateUserPluginsService(user, pluginKey, action);
|
const userPluginsService = await updateUserPluginsService(user, pluginKey, action);
|
||||||
@@ -95,32 +97,55 @@ const updateUserPluginsController = async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth) {
|
if (auth == null) {
|
||||||
const keys = Object.keys(auth);
|
return res.status(200).send();
|
||||||
const values = Object.values(auth);
|
}
|
||||||
if (action === 'install' && keys.length > 0) {
|
|
||||||
for (let i = 0; i < keys.length; i++) {
|
let keys = Object.keys(auth);
|
||||||
authService = await updateUserPluginAuth(user.id, keys[i], pluginKey, values[i]);
|
if (keys.length === 0 && pluginKey !== Tools.web_search) {
|
||||||
if (authService instanceof Error) {
|
return res.status(200).send();
|
||||||
logger.error('[authService]', authService);
|
}
|
||||||
const { status, message } = authService;
|
const values = Object.values(auth);
|
||||||
res.status(status).send({ message });
|
|
||||||
}
|
/** @type {number} */
|
||||||
|
let status = 200;
|
||||||
|
/** @type {string} */
|
||||||
|
let message;
|
||||||
|
/** @type {IPluginAuth | Error} */
|
||||||
|
let authService;
|
||||||
|
|
||||||
|
if (pluginKey === Tools.web_search) {
|
||||||
|
/** @type {TCustomConfig['webSearch']} */
|
||||||
|
const webSearchConfig = req.app.locals?.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 } = authService);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (action === 'uninstall' && keys.length > 0) {
|
} else if (action === 'uninstall') {
|
||||||
for (let i = 0; i < keys.length; i++) {
|
for (let i = 0; i < keys.length; i++) {
|
||||||
authService = await deleteUserPluginAuth(user.id, keys[i]);
|
authService = await deleteUserPluginAuth(user.id, keys[i]);
|
||||||
if (authService instanceof Error) {
|
if (authService instanceof Error) {
|
||||||
logger.error('[authService]', authService);
|
logger.error('[authService]', authService);
|
||||||
const { status, message } = authService;
|
({ status, message } = authService);
|
||||||
res.status(status).send({ message });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).send();
|
if (status === 200) {
|
||||||
|
return res.status(status).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(status).send({ message });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('[updateUserPluginsController]', err);
|
logger.error('[updateUserPluginsController]', err);
|
||||||
return res.status(500).json({ message: 'Something went wrong.' });
|
return res.status(500).json({ message: 'Something went wrong.' });
|
||||||
@@ -138,7 +163,11 @@ const deleteUserController = async (req, res) => {
|
|||||||
await Balance.deleteMany({ user: user._id }); // delete user balances
|
await Balance.deleteMany({ user: user._id }); // delete user balances
|
||||||
await deletePresets(user.id); // delete user presets
|
await deletePresets(user.id); // delete user presets
|
||||||
/* TODO: Delete Assistant Threads */
|
/* TODO: Delete Assistant Threads */
|
||||||
await deleteConvos(user.id); // delete user convos
|
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 deleteUserPluginAuth(user.id, null, true); // delete user plugin auth
|
||||||
await deleteUserById(user.id); // delete user
|
await deleteUserById(user.id); // delete user
|
||||||
await deleteAllSharedLinks(user.id); // delete user shared links
|
await deleteAllSharedLinks(user.id); // delete user shared links
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
const { nanoid } = require('nanoid');
|
const { nanoid } = require('nanoid');
|
||||||
|
const { sendEvent } = require('@librechat/api');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { Tools, StepTypes, FileContext } = require('librechat-data-provider');
|
const { Tools, StepTypes, FileContext } = require('librechat-data-provider');
|
||||||
const {
|
const {
|
||||||
EnvVar,
|
EnvVar,
|
||||||
@@ -12,7 +14,6 @@ const {
|
|||||||
const { processCodeOutput } = require('~/server/services/Files/Code/process');
|
const { processCodeOutput } = require('~/server/services/Files/Code/process');
|
||||||
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
||||||
const { saveBase64Image } = require('~/server/services/Files/process');
|
const { saveBase64Image } = require('~/server/services/Files/process');
|
||||||
const { logger, sendEvent } = require('~/config');
|
|
||||||
|
|
||||||
class ModelEndHandler {
|
class ModelEndHandler {
|
||||||
/**
|
/**
|
||||||
@@ -237,6 +238,28 @@ function createToolEndCallback({ req, res, artifactPromises }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
if (output.artifact.content) {
|
||||||
/** @type {FormattedContent[]} */
|
/** @type {FormattedContent[]} */
|
||||||
const content = output.artifact.content;
|
const content = output.artifact.content;
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
// const { HttpsProxyAgent } = require('https-proxy-agent');
|
|
||||||
// const {
|
|
||||||
// Constants,
|
|
||||||
// ImageDetail,
|
|
||||||
// EModelEndpoint,
|
|
||||||
// resolveHeaders,
|
|
||||||
// validateVisionModel,
|
|
||||||
// mapModelToAzureConfig,
|
|
||||||
// } = require('librechat-data-provider');
|
|
||||||
require('events').EventEmitter.defaultMaxListeners = 100;
|
require('events').EventEmitter.defaultMaxListeners = 100;
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const {
|
||||||
|
sendEvent,
|
||||||
|
createRun,
|
||||||
|
Tokenizer,
|
||||||
|
memoryInstructions,
|
||||||
|
createMemoryProcessor,
|
||||||
|
} = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
Callback,
|
Callback,
|
||||||
GraphEvents,
|
GraphEvents,
|
||||||
@@ -19,28 +18,30 @@ const {
|
|||||||
} = require('@librechat/agents');
|
} = require('@librechat/agents');
|
||||||
const {
|
const {
|
||||||
Constants,
|
Constants,
|
||||||
|
Permissions,
|
||||||
VisionModes,
|
VisionModes,
|
||||||
ContentTypes,
|
ContentTypes,
|
||||||
EModelEndpoint,
|
EModelEndpoint,
|
||||||
KnownEndpoints,
|
KnownEndpoints,
|
||||||
|
PermissionTypes,
|
||||||
isAgentsEndpoint,
|
isAgentsEndpoint,
|
||||||
AgentCapabilities,
|
AgentCapabilities,
|
||||||
bedrockInputSchema,
|
bedrockInputSchema,
|
||||||
removeNullishValues,
|
removeNullishValues,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
|
const { DynamicStructuredTool } = require('@langchain/core/tools');
|
||||||
|
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
|
||||||
const { getCustomEndpointConfig, checkCapability } = require('~/server/services/Config');
|
const { getCustomEndpointConfig, checkCapability } = require('~/server/services/Config');
|
||||||
const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts');
|
const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts');
|
||||||
|
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
|
||||||
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
||||||
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
|
const { setMemory, deleteMemory, getFormattedMemories } = require('~/models');
|
||||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||||
const initOpenAI = require('~/server/services/Endpoints/openAI/initialize');
|
const initOpenAI = require('~/server/services/Endpoints/openAI/initialize');
|
||||||
const Tokenizer = require('~/server/services/Tokenizer');
|
const { checkAccess } = require('~/server/middleware/roles/access');
|
||||||
const BaseClient = require('~/app/clients/BaseClient');
|
const BaseClient = require('~/app/clients/BaseClient');
|
||||||
const { logger, sendEvent } = require('~/config');
|
const { loadAgent } = require('~/models/Agent');
|
||||||
const { createRun } = require('./run');
|
const { getMCPManager } = require('~/config');
|
||||||
|
|
||||||
/** @typedef {import('@librechat/agents').MessageContentComplex} MessageContentComplex */
|
|
||||||
/** @typedef {import('@langchain/core/runnables').RunnableConfig} RunnableConfig */
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {ServerRequest} req
|
* @param {ServerRequest} req
|
||||||
@@ -60,12 +61,8 @@ const legacyContentEndpoints = new Set([KnownEndpoints.groq, KnownEndpoints.deep
|
|||||||
|
|
||||||
const noSystemModelRegex = [/\b(o1-preview|o1-mini|amazon\.titan-text)\b/gi];
|
const noSystemModelRegex = [/\b(o1-preview|o1-mini|amazon\.titan-text)\b/gi];
|
||||||
|
|
||||||
// const { processMemory, memoryInstructions } = require('~/server/services/Endpoints/agents/memory');
|
|
||||||
// const { getFormattedMemories } = require('~/models/Memory');
|
|
||||||
// const { getCurrentDateTime } = require('~/utils');
|
|
||||||
|
|
||||||
function createTokenCounter(encoding) {
|
function createTokenCounter(encoding) {
|
||||||
return (message) => {
|
return function (message) {
|
||||||
const countTokens = (text) => Tokenizer.getTokenCount(text, encoding);
|
const countTokens = (text) => Tokenizer.getTokenCount(text, encoding);
|
||||||
return getTokenCountForMessage(message, countTokens);
|
return getTokenCountForMessage(message, countTokens);
|
||||||
};
|
};
|
||||||
@@ -126,6 +123,8 @@ class AgentClient extends BaseClient {
|
|||||||
this.usage;
|
this.usage;
|
||||||
/** @type {Record<string, number>} */
|
/** @type {Record<string, number>} */
|
||||||
this.indexTokenCountMap = {};
|
this.indexTokenCountMap = {};
|
||||||
|
/** @type {(messages: BaseMessage[]) => Promise<void>} */
|
||||||
|
this.processMemory;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,55 +139,10 @@ class AgentClient extends BaseClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
* `AgentClient` is not opinionated about vision requests, so we don't do anything here
|
||||||
* Checks if the model is a vision model based on request attachments and sets the appropriate options:
|
|
||||||
* - Sets `this.modelOptions.model` to `gpt-4-vision-preview` if the request is a vision request.
|
|
||||||
* - Sets `this.isVisionModel` to `true` if vision request.
|
|
||||||
* - Deletes `this.modelOptions.stop` if vision request.
|
|
||||||
* @param {MongoFile[]} attachments
|
* @param {MongoFile[]} attachments
|
||||||
*/
|
*/
|
||||||
checkVisionRequest(attachments) {
|
checkVisionRequest() {}
|
||||||
// if (!attachments) {
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
// const availableModels = this.options.modelsConfig?.[this.options.endpoint];
|
|
||||||
// if (!availableModels) {
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
// let visionRequestDetected = false;
|
|
||||||
// for (const file of attachments) {
|
|
||||||
// if (file?.type?.includes('image')) {
|
|
||||||
// visionRequestDetected = true;
|
|
||||||
// break;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// if (!visionRequestDetected) {
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
// this.isVisionModel = validateVisionModel({ model: this.modelOptions.model, availableModels });
|
|
||||||
// if (this.isVisionModel) {
|
|
||||||
// delete this.modelOptions.stop;
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
// for (const model of availableModels) {
|
|
||||||
// if (!validateVisionModel({ model, availableModels })) {
|
|
||||||
// continue;
|
|
||||||
// }
|
|
||||||
// this.modelOptions.model = model;
|
|
||||||
// this.isVisionModel = true;
|
|
||||||
// delete this.modelOptions.stop;
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
// if (!availableModels.includes(this.defaultVisionModel)) {
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
// if (!validateVisionModel({ model: this.defaultVisionModel, availableModels })) {
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
// this.modelOptions.model = this.defaultVisionModel;
|
|
||||||
// this.isVisionModel = true;
|
|
||||||
// delete this.modelOptions.stop;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSaveOptions() {
|
getSaveOptions() {
|
||||||
// TODO:
|
// TODO:
|
||||||
@@ -272,24 +226,6 @@ class AgentClient extends BaseClient {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
.trim();
|
.trim();
|
||||||
// this.systemMessage = getCurrentDateTime();
|
|
||||||
// const { withKeys, withoutKeys } = await getFormattedMemories({
|
|
||||||
// userId: this.options.req.user.id,
|
|
||||||
// });
|
|
||||||
// processMemory({
|
|
||||||
// userId: this.options.req.user.id,
|
|
||||||
// message: this.options.req.body.text,
|
|
||||||
// parentMessageId,
|
|
||||||
// memory: withKeys,
|
|
||||||
// thread_id: this.conversationId,
|
|
||||||
// }).catch((error) => {
|
|
||||||
// logger.error('Memory Agent failed to process memory', error);
|
|
||||||
// });
|
|
||||||
|
|
||||||
// this.systemMessage += '\n\n' + memoryInstructions;
|
|
||||||
// if (withoutKeys) {
|
|
||||||
// this.systemMessage += `\n\n# Existing memory about the user:\n${withoutKeys}`;
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (this.options.attachments) {
|
if (this.options.attachments) {
|
||||||
const attachments = await this.options.attachments;
|
const attachments = await this.options.attachments;
|
||||||
@@ -373,6 +309,37 @@ class AgentClient extends BaseClient {
|
|||||||
systemContent = this.augmentedPrompt + systemContent;
|
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 = 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) {
|
if (systemContent) {
|
||||||
this.options.agent.instructions = systemContent;
|
this.options.agent.instructions = systemContent;
|
||||||
}
|
}
|
||||||
@@ -402,9 +369,150 @@ class AgentClient extends BaseClient {
|
|||||||
opts.getReqData({ promptTokens });
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<string | undefined>}
|
||||||
|
*/
|
||||||
|
async useMemory() {
|
||||||
|
const user = this.options.req.user;
|
||||||
|
if (user.personalization?.memories === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hasAccess = await checkAccess(user, PermissionTypes.MEMORIES, [Permissions.USE]);
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
logger.debug(
|
||||||
|
`[api/server/controllers/agents/client.js #useMemory] User ${user.id} does not have USE permission for memories`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/** @type {TCustomConfig['memory']} */
|
||||||
|
const memoryConfig = this.options.req?.app?.locals?.memory;
|
||||||
|
if (!memoryConfig || memoryConfig.disabled === true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Agent} */
|
||||||
|
let prelimAgent;
|
||||||
|
const allowedProviders = new Set(
|
||||||
|
this.options.req?.app?.locals?.[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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
deleteMemory,
|
||||||
|
getFormattedMemories,
|
||||||
|
},
|
||||||
|
res: this.options.res,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.processMemory = processMemory;
|
||||||
|
return withoutKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {BaseMessage[]} messages
|
||||||
|
* @returns {Promise<void | (TAttachment | null)[]>}
|
||||||
|
*/
|
||||||
|
async runMemory(messages) {
|
||||||
|
try {
|
||||||
|
if (this.processMemory == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/** @type {TCustomConfig['memory']} */
|
||||||
|
const memoryConfig = this.options.req?.app?.locals?.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)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await this.processMemory(messagesToProcess);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Memory Agent failed to process memory', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {sendCompletion} */
|
/** @type {sendCompletion} */
|
||||||
async sendCompletion(payload, opts = {}) {
|
async sendCompletion(payload, opts = {}) {
|
||||||
await this.chatCompletion({
|
await this.chatCompletion({
|
||||||
@@ -543,104 +651,17 @@ class AgentClient extends BaseClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async chatCompletion({ payload, abortController = null }) {
|
async chatCompletion({ payload, abortController = null }) {
|
||||||
/** @type {Partial<RunnableConfig> & { version: 'v1' | 'v2'; run_id?: string; streamMode: string }} */
|
/** @type {Partial<GraphRunnableConfig>} */
|
||||||
let config;
|
let config;
|
||||||
/** @type {ReturnType<createRun>} */
|
/** @type {ReturnType<createRun>} */
|
||||||
let run;
|
let run;
|
||||||
|
/** @type {Promise<(TAttachment | null)[] | undefined>} */
|
||||||
|
let memoryPromise;
|
||||||
try {
|
try {
|
||||||
if (!abortController) {
|
if (!abortController) {
|
||||||
abortController = new AbortController();
|
abortController = new AbortController();
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (this.options.headers) {
|
|
||||||
// opts.defaultHeaders = { ...opts.defaultHeaders, ...this.options.headers };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if (this.options.proxy) {
|
|
||||||
// opts.httpAgent = new HttpsProxyAgent(this.options.proxy);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if (this.isVisionModel) {
|
|
||||||
// modelOptions.max_tokens = 4000;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// /** @type {TAzureConfig | undefined} */
|
|
||||||
// const azureConfig = this.options?.req?.app?.locals?.[EModelEndpoint.azureOpenAI];
|
|
||||||
|
|
||||||
// if (
|
|
||||||
// (this.azure && this.isVisionModel && azureConfig) ||
|
|
||||||
// (azureConfig && this.isVisionModel && this.options.endpoint === EModelEndpoint.azureOpenAI)
|
|
||||||
// ) {
|
|
||||||
// const { modelGroupMap, groupMap } = azureConfig;
|
|
||||||
// const {
|
|
||||||
// azureOptions,
|
|
||||||
// baseURL,
|
|
||||||
// headers = {},
|
|
||||||
// serverless,
|
|
||||||
// } = mapModelToAzureConfig({
|
|
||||||
// modelName: modelOptions.model,
|
|
||||||
// modelGroupMap,
|
|
||||||
// groupMap,
|
|
||||||
// });
|
|
||||||
// opts.defaultHeaders = resolveHeaders(headers);
|
|
||||||
// this.langchainProxy = extractBaseURL(baseURL);
|
|
||||||
// this.apiKey = azureOptions.azureOpenAIApiKey;
|
|
||||||
|
|
||||||
// const groupName = modelGroupMap[modelOptions.model].group;
|
|
||||||
// this.options.addParams = azureConfig.groupMap[groupName].addParams;
|
|
||||||
// this.options.dropParams = azureConfig.groupMap[groupName].dropParams;
|
|
||||||
// // Note: `forcePrompt` not re-assigned as only chat models are vision models
|
|
||||||
|
|
||||||
// this.azure = !serverless && azureOptions;
|
|
||||||
// this.azureEndpoint =
|
|
||||||
// !serverless && genAzureChatCompletion(this.azure, modelOptions.model, this);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if (this.azure || this.options.azure) {
|
|
||||||
// /* Azure Bug, extremely short default `max_tokens` response */
|
|
||||||
// if (!modelOptions.max_tokens && modelOptions.model === 'gpt-4-vision-preview') {
|
|
||||||
// modelOptions.max_tokens = 4000;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// /* Azure does not accept `model` in the body, so we need to remove it. */
|
|
||||||
// delete modelOptions.model;
|
|
||||||
|
|
||||||
// opts.baseURL = this.langchainProxy
|
|
||||||
// ? constructAzureURL({
|
|
||||||
// baseURL: this.langchainProxy,
|
|
||||||
// azureOptions: this.azure,
|
|
||||||
// })
|
|
||||||
// : this.azureEndpoint.split(/(?<!\/)\/(chat|completion)\//)[0];
|
|
||||||
|
|
||||||
// opts.defaultQuery = { 'api-version': this.azure.azureOpenAIApiVersion };
|
|
||||||
// opts.defaultHeaders = { ...opts.defaultHeaders, 'api-key': this.apiKey };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if (process.env.OPENAI_ORGANIZATION) {
|
|
||||||
// opts.organization = process.env.OPENAI_ORGANIZATION;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if (this.options.addParams && typeof this.options.addParams === 'object') {
|
|
||||||
// modelOptions = {
|
|
||||||
// ...modelOptions,
|
|
||||||
// ...this.options.addParams,
|
|
||||||
// };
|
|
||||||
// logger.debug('[api/server/controllers/agents/client.js #chatCompletion] added params', {
|
|
||||||
// addParams: this.options.addParams,
|
|
||||||
// modelOptions,
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if (this.options.dropParams && Array.isArray(this.options.dropParams)) {
|
|
||||||
// this.options.dropParams.forEach((param) => {
|
|
||||||
// delete modelOptions[param];
|
|
||||||
// });
|
|
||||||
// logger.debug('[api/server/controllers/agents/client.js #chatCompletion] dropped params', {
|
|
||||||
// dropParams: this.options.dropParams,
|
|
||||||
// modelOptions,
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
/** @type {TCustomConfig['endpoints']['agents']} */
|
/** @type {TCustomConfig['endpoints']['agents']} */
|
||||||
const agentsEConfig = this.options.req.app.locals[EModelEndpoint.agents];
|
const agentsEConfig = this.options.req.app.locals[EModelEndpoint.agents];
|
||||||
|
|
||||||
@@ -650,6 +671,7 @@ class AgentClient extends BaseClient {
|
|||||||
last_agent_index: this.agentConfigs?.size ?? 0,
|
last_agent_index: this.agentConfigs?.size ?? 0,
|
||||||
user_id: this.user ?? this.options.req.user?.id,
|
user_id: this.user ?? this.options.req.user?.id,
|
||||||
hide_sequential_outputs: this.options.agent.hide_sequential_outputs,
|
hide_sequential_outputs: this.options.agent.hide_sequential_outputs,
|
||||||
|
user: this.options.req.user,
|
||||||
},
|
},
|
||||||
recursionLimit: agentsEConfig?.recursionLimit,
|
recursionLimit: agentsEConfig?.recursionLimit,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
@@ -737,6 +759,10 @@ class AgentClient extends BaseClient {
|
|||||||
messages = addCacheControl(messages);
|
messages = addCacheControl(messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
memoryPromise = this.runMemory(messages);
|
||||||
|
}
|
||||||
|
|
||||||
run = await createRun({
|
run = await createRun({
|
||||||
agent,
|
agent,
|
||||||
req: this.options.req,
|
req: this.options.req,
|
||||||
@@ -772,10 +798,9 @@ class AgentClient extends BaseClient {
|
|||||||
run.Graph.contentData = contentData;
|
run.Graph.contentData = contentData;
|
||||||
}
|
}
|
||||||
|
|
||||||
const encoding = this.getEncoding();
|
|
||||||
await run.processStream({ messages }, config, {
|
await run.processStream({ messages }, config, {
|
||||||
keepContent: i !== 0,
|
keepContent: i !== 0,
|
||||||
tokenCounter: createTokenCounter(encoding),
|
tokenCounter: createTokenCounter(this.getEncoding()),
|
||||||
indexTokenCountMap: currentIndexCountMap,
|
indexTokenCountMap: currentIndexCountMap,
|
||||||
maxContextTokens: agent.maxContextTokens,
|
maxContextTokens: agent.maxContextTokens,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
@@ -890,6 +915,12 @@ class AgentClient extends BaseClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (memoryPromise) {
|
||||||
|
const attachments = await memoryPromise;
|
||||||
|
if (attachments && attachments.length > 0) {
|
||||||
|
this.artifactPromises.push(...attachments);
|
||||||
|
}
|
||||||
|
}
|
||||||
await this.recordCollectedUsage({ context: 'message' });
|
await this.recordCollectedUsage({ context: 'message' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -898,6 +929,12 @@ class AgentClient extends BaseClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (memoryPromise) {
|
||||||
|
const attachments = await memoryPromise;
|
||||||
|
if (attachments && attachments.length > 0) {
|
||||||
|
this.artifactPromises.push(...attachments);
|
||||||
|
}
|
||||||
|
}
|
||||||
logger.error(
|
logger.error(
|
||||||
'[api/server/controllers/agents/client.js #sendCompletion] Operation aborted',
|
'[api/server/controllers/agents/client.js #sendCompletion] Operation aborted',
|
||||||
err,
|
err,
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
|||||||
// Save user message if needed
|
// Save user message if needed
|
||||||
if (!client.skipSaveUserMessage) {
|
if (!client.skipSaveUserMessage) {
|
||||||
await saveMessage(req, userMessage, {
|
await saveMessage(req, userMessage, {
|
||||||
context: 'api/server/controllers/agents/request.js - don\'t skip saving user message',
|
context: "api/server/controllers/agents/request.js - don't skip saving user message",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
const { Run, Providers } = require('@librechat/agents');
|
|
||||||
const { providerEndpointMap, KnownEndpoints } = require('librechat-data-provider');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {import('@librechat/agents').t} t
|
|
||||||
* @typedef {import('@librechat/agents').StandardGraphConfig} StandardGraphConfig
|
|
||||||
* @typedef {import('@librechat/agents').StreamEventData} StreamEventData
|
|
||||||
* @typedef {import('@librechat/agents').EventHandler} EventHandler
|
|
||||||
* @typedef {import('@librechat/agents').GraphEvents} GraphEvents
|
|
||||||
* @typedef {import('@librechat/agents').LLMConfig} LLMConfig
|
|
||||||
* @typedef {import('@librechat/agents').IState} IState
|
|
||||||
*/
|
|
||||||
|
|
||||||
const customProviders = new Set([
|
|
||||||
Providers.XAI,
|
|
||||||
Providers.OLLAMA,
|
|
||||||
Providers.DEEPSEEK,
|
|
||||||
Providers.OPENROUTER,
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new Run instance with custom handlers and configuration.
|
|
||||||
*
|
|
||||||
* @param {Object} options - The options for creating the Run instance.
|
|
||||||
* @param {ServerRequest} [options.req] - The server request.
|
|
||||||
* @param {string | undefined} [options.runId] - Optional run ID; otherwise, a new run ID will be generated.
|
|
||||||
* @param {Agent} options.agent - The agent for this run.
|
|
||||||
* @param {AbortSignal} options.signal - The signal for this run.
|
|
||||||
* @param {Record<GraphEvents, EventHandler> | undefined} [options.customHandlers] - Custom event handlers.
|
|
||||||
* @param {boolean} [options.streaming=true] - Whether to use streaming.
|
|
||||||
* @param {boolean} [options.streamUsage=true] - Whether to stream usage information.
|
|
||||||
* @returns {Promise<Run<IState>>} A promise that resolves to a new Run instance.
|
|
||||||
*/
|
|
||||||
async function createRun({
|
|
||||||
runId,
|
|
||||||
agent,
|
|
||||||
signal,
|
|
||||||
customHandlers,
|
|
||||||
streaming = true,
|
|
||||||
streamUsage = true,
|
|
||||||
}) {
|
|
||||||
const provider = providerEndpointMap[agent.provider] ?? agent.provider;
|
|
||||||
/** @type {LLMConfig} */
|
|
||||||
const llmConfig = Object.assign(
|
|
||||||
{
|
|
||||||
provider,
|
|
||||||
streaming,
|
|
||||||
streamUsage,
|
|
||||||
},
|
|
||||||
agent.model_parameters,
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Resolves issues with new OpenAI usage field */
|
|
||||||
if (
|
|
||||||
customProviders.has(agent.provider) ||
|
|
||||||
(agent.provider === Providers.OPENAI && agent.endpoint !== agent.provider)
|
|
||||||
) {
|
|
||||||
llmConfig.streamUsage = false;
|
|
||||||
llmConfig.usage = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {'reasoning_content' | 'reasoning'} */
|
|
||||||
let reasoningKey;
|
|
||||||
if (
|
|
||||||
llmConfig.configuration?.baseURL?.includes(KnownEndpoints.openrouter) ||
|
|
||||||
(agent.endpoint && agent.endpoint.toLowerCase().includes(KnownEndpoints.openrouter))
|
|
||||||
) {
|
|
||||||
reasoningKey = 'reasoning';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {StandardGraphConfig} */
|
|
||||||
const graphConfig = {
|
|
||||||
signal,
|
|
||||||
llmConfig,
|
|
||||||
reasoningKey,
|
|
||||||
tools: agent.tools,
|
|
||||||
instructions: agent.instructions,
|
|
||||||
additional_instructions: agent.additional_instructions,
|
|
||||||
// toolEnd: agent.end_after_tools,
|
|
||||||
};
|
|
||||||
|
|
||||||
// TEMPORARY FOR TESTING
|
|
||||||
if (agent.provider === Providers.ANTHROPIC || agent.provider === Providers.BEDROCK) {
|
|
||||||
graphConfig.streamBuffer = 2000;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Run.create({
|
|
||||||
runId,
|
|
||||||
graphConfig,
|
|
||||||
customHandlers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { createRun };
|
|
||||||
@@ -18,11 +18,13 @@ const {
|
|||||||
} = require('~/models/Agent');
|
} = require('~/models/Agent');
|
||||||
const { uploadImageBuffer, filterFile } = require('~/server/services/Files/process');
|
const { uploadImageBuffer, filterFile } = require('~/server/services/Files/process');
|
||||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||||
|
const { resizeAvatar } = require('@librechat/auth');
|
||||||
const { refreshS3Url } = require('~/server/services/Files/S3/crud');
|
const { refreshS3Url } = require('~/server/services/Files/S3/crud');
|
||||||
const { updateAction, getActions } = require('~/models/Action');
|
const { updateAction, getActions } = require('~/models/Action');
|
||||||
const { updateAgentProjects } = require('~/models/Agent');
|
const { updateAgentProjects } = require('~/models/Agent');
|
||||||
const { getProjectByName } = require('~/models/Project');
|
const { getProjectByName } = require('~/models/Project');
|
||||||
const { deleteFileByFilter } = require('~/models/File');
|
const { deleteFileByFilter } = require('~/models/File');
|
||||||
|
const { revertAgentVersion } = require('~/models/Agent');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
const systemTools = {
|
const systemTools = {
|
||||||
@@ -104,11 +106,13 @@ const getAgentHandler = async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Agent not found' });
|
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) {
|
if (agent.avatar && agent.avatar?.source === FileSources.s3) {
|
||||||
const originalUrl = agent.avatar.filepath;
|
const originalUrl = agent.avatar.filepath;
|
||||||
agent.avatar.filepath = await refreshS3Url(agent.avatar);
|
agent.avatar.filepath = await refreshS3Url(agent.avatar);
|
||||||
if (originalUrl !== agent.avatar.filepath) {
|
if (originalUrl !== agent.avatar.filepath) {
|
||||||
await updateAgent({ id }, { avatar: agent.avatar });
|
await updateAgent({ id }, { avatar: agent.avatar }, { updatingUserId: req.user.id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +131,7 @@ const getAgentHandler = async (req, res) => {
|
|||||||
author: agent.author,
|
author: agent.author,
|
||||||
projectIds: agent.projectIds,
|
projectIds: agent.projectIds,
|
||||||
isCollaborative: agent.isCollaborative,
|
isCollaborative: agent.isCollaborative,
|
||||||
|
version: agent.version,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return res.status(200).json(agent);
|
return res.status(200).json(agent);
|
||||||
@@ -164,10 +169,18 @@ const updateAgentHandler = async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let updatedAgent =
|
/** @type {boolean} */
|
||||||
Object.keys(updateData).length > 0 ? await updateAgent({ id }, updateData) : existingAgent;
|
const isProjectUpdate = (projectIds?.length ?? 0) > 0 || (removeProjectIds?.length ?? 0) > 0;
|
||||||
|
|
||||||
if (projectIds || removeProjectIds) {
|
let updatedAgent =
|
||||||
|
Object.keys(updateData).length > 0
|
||||||
|
? await updateAgent({ id }, updateData, {
|
||||||
|
updatingUserId: req.user.id,
|
||||||
|
skipVersioning: isProjectUpdate,
|
||||||
|
})
|
||||||
|
: existingAgent;
|
||||||
|
|
||||||
|
if (isProjectUpdate) {
|
||||||
updatedAgent = await updateAgentProjects({
|
updatedAgent = await updateAgentProjects({
|
||||||
user: req.user,
|
user: req.user,
|
||||||
agentId: id,
|
agentId: id,
|
||||||
@@ -187,6 +200,14 @@ const updateAgentHandler = async (req, res) => {
|
|||||||
return res.json(updatedAgent);
|
return res.json(updatedAgent);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[/Agents/:id] Error updating Agent', error);
|
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 });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -359,12 +380,27 @@ const uploadAgentAvatarHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buffer = await fs.readFile(req.file.path);
|
const buffer = await fs.readFile(req.file.path);
|
||||||
const image = await uploadImageBuffer({
|
|
||||||
req,
|
const fileStrategy = req.app.locals.fileStrategy;
|
||||||
context: FileContext.avatar,
|
|
||||||
metadata: { buffer },
|
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;
|
let _avatar;
|
||||||
try {
|
try {
|
||||||
const agent = await getAgent({ id: agent_id });
|
const agent = await getAgent({ id: agent_id });
|
||||||
@@ -389,11 +425,15 @@ const uploadAgentAvatarHandler = async (req, res) => {
|
|||||||
const data = {
|
const data = {
|
||||||
avatar: {
|
avatar: {
|
||||||
filepath: image.filepath,
|
filepath: image.filepath,
|
||||||
source: req.app.locals.fileStrategy,
|
source: image.source,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
promises.push(await updateAgent({ id: agent_id, author: req.user.id }, data));
|
promises.push(
|
||||||
|
await updateAgent({ id: agent_id, author: req.user.id }, data, {
|
||||||
|
updatingUserId: req.user.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const resolved = await Promise.all(promises);
|
const resolved = await Promise.all(promises);
|
||||||
res.status(201).json(resolved[0]);
|
res.status(201).json(resolved[0]);
|
||||||
@@ -411,6 +451,66 @@ const uploadAgentAvatarHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Agent>} 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 isAdmin = req.user.role === SystemRoles.ADMIN;
|
||||||
|
const existingAgent = await getAgent({ id });
|
||||||
|
|
||||||
|
if (!existingAgent) {
|
||||||
|
return res.status(404).json({ error: 'Agent not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthor = existingAgent.author.toString() === req.user.id;
|
||||||
|
const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor;
|
||||||
|
|
||||||
|
if (!hasEditPermission) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'You do not have permission to modify this non-collaborative agent',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createAgent: createAgentHandler,
|
createAgent: createAgentHandler,
|
||||||
getAgent: getAgentHandler,
|
getAgent: getAgentHandler,
|
||||||
@@ -419,4 +519,5 @@ module.exports = {
|
|||||||
deleteAgent: deleteAgentHandler,
|
deleteAgent: deleteAgentHandler,
|
||||||
getListAgents: getListAgentsHandler,
|
getListAgents: getListAgentsHandler,
|
||||||
uploadAgentAvatar: uploadAgentAvatarHandler,
|
uploadAgentAvatar: uploadAgentAvatarHandler,
|
||||||
|
revertAgentVersion: revertAgentVersionHandler,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -326,8 +326,15 @@ const chatV1 = async (req, res) => {
|
|||||||
|
|
||||||
file_ids = files.map(({ file_id }) => file_id);
|
file_ids = files.map(({ file_id }) => file_id);
|
||||||
if (file_ids.length || thread_file_ids.length) {
|
if (file_ids.length || thread_file_ids.length) {
|
||||||
userMessage.file_ids = file_ids;
|
|
||||||
attachedFileIds = new Set([...file_ids, ...thread_file_ids]);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const { generate2FATempToken } = require('~/server/services/twoFactorService');
|
const { generate2FATempToken } = require('~/server/services/twoFactorService');
|
||||||
const { setAuthTokens } = require('~/server/services/AuthService');
|
const { setAuthTokens } = require('@librechat/auth');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
const loginController = async (req, res) => {
|
const loginController = async (req, res) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const cookies = require('cookie');
|
const cookies = require('cookie');
|
||||||
const { Issuer } = require('openid-client');
|
const { getOpenIdConfig } = require('@librechat/auth');
|
||||||
const { logoutUser } = require('~/server/services/AuthService');
|
const { logoutUser } = require('@librechat/auth');
|
||||||
const { isEnabled } = require('~/server/utils');
|
const { isEnabled } = require('~/server/utils');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
@@ -10,20 +10,29 @@ const logoutController = async (req, res) => {
|
|||||||
const logout = await logoutUser(req, refreshToken);
|
const logout = await logoutUser(req, refreshToken);
|
||||||
const { status, message } = logout;
|
const { status, message } = logout;
|
||||||
res.clearCookie('refreshToken');
|
res.clearCookie('refreshToken');
|
||||||
|
res.clearCookie('token_provider');
|
||||||
const response = { message };
|
const response = { message };
|
||||||
if (
|
if (
|
||||||
req.user.openidId != null &&
|
req.user.openidId != null &&
|
||||||
isEnabled(process.env.OPENID_USE_END_SESSION_ENDPOINT) &&
|
isEnabled(process.env.OPENID_USE_END_SESSION_ENDPOINT) &&
|
||||||
process.env.OPENID_ISSUER
|
process.env.OPENID_ISSUER
|
||||||
) {
|
) {
|
||||||
const issuer = await Issuer.discover(process.env.OPENID_ISSUER);
|
const openIdConfig = getOpenIdConfig();
|
||||||
const redirect = issuer.metadata.end_session_endpoint;
|
if (!openIdConfig) {
|
||||||
if (!redirect) {
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'[logoutController] end_session_endpoint not found in OpenID issuer metadata. Please verify that the issuer is correct.',
|
'[logoutController] OpenID config not found. Please verify that the open id configuration and initialization are correct.',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
response.redirect = redirect;
|
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);
|
return res.status(status).send(response);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const {
|
const {
|
||||||
verifyTOTP,
|
verifyTOTP,
|
||||||
verifyBackupCode,
|
|
||||||
getTOTPSecret,
|
getTOTPSecret,
|
||||||
|
verifyBackupCode,
|
||||||
} = require('~/server/services/twoFactorService');
|
} = require('~/server/services/twoFactorService');
|
||||||
const { setAuthTokens } = require('~/server/services/AuthService');
|
const { setAuthTokens } = require('@librechat/auth');
|
||||||
const { getUserById } = require('~/models/userMethods');
|
const { getUserById } = require('~/models');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies the 2FA code during login using a temporary token.
|
* Verifies the 2FA code during login using a temporary token.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const {
|
|||||||
Permissions,
|
Permissions,
|
||||||
ToolCallTypes,
|
ToolCallTypes,
|
||||||
PermissionTypes,
|
PermissionTypes,
|
||||||
|
loadWebSearchAuth,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process');
|
const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process');
|
||||||
const { processCodeOutput } = require('~/server/services/Files/Code/process');
|
const { processCodeOutput } = require('~/server/services/Files/Code/process');
|
||||||
@@ -24,6 +25,36 @@ const toolAccessPermType = {
|
|||||||
[Tools.execute_code]: PermissionTypes.RUN_CODE,
|
[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<void>} A promise that resolves when the function has completed
|
||||||
|
*/
|
||||||
|
const verifyWebSearchAuth = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
/** @type {TCustomConfig['webSearch']} */
|
||||||
|
const webSearchConfig = req.app.locals?.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 {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 {ServerResponse} res - The response object, used to send back the desired HTTP response.
|
||||||
@@ -32,6 +63,9 @@ const toolAccessPermType = {
|
|||||||
const verifyToolAuth = async (req, res) => {
|
const verifyToolAuth = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { toolId } = req.params;
|
const { toolId } = req.params;
|
||||||
|
if (toolId === Tools.web_search) {
|
||||||
|
return await verifyWebSearchAuth(req, res);
|
||||||
|
}
|
||||||
const authFields = fieldsMap[toolId];
|
const authFields = fieldsMap[toolId];
|
||||||
if (!authFields) {
|
if (!authFields) {
|
||||||
res.status(404).json({ message: 'Tool not found' });
|
res.status(404).json({ message: 'Tool not found' });
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ const passport = require('passport');
|
|||||||
const mongoSanitize = require('express-mongo-sanitize');
|
const mongoSanitize = require('express-mongo-sanitize');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const cookieParser = require('cookie-parser');
|
const cookieParser = require('cookie-parser');
|
||||||
const { jwtLogin, passportLogin } = require('~/strategies');
|
const { connectDb, indexSync } = require('~/db');
|
||||||
const { connectDb, indexSync } = require('~/lib/db');
|
|
||||||
|
const { initAuth, passportLogin, ldapLogin, jwtLogin } = require('@librechat/auth');
|
||||||
const { isEnabled } = require('~/server/utils');
|
const { isEnabled } = require('~/server/utils');
|
||||||
const { ldapLogin } = require('~/strategies');
|
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
const validateImageRequest = require('./middleware/validateImageRequest');
|
const validateImageRequest = require('./middleware/validateImageRequest');
|
||||||
const errorController = require('./controllers/ErrorController');
|
const errorController = require('./controllers/ErrorController');
|
||||||
@@ -21,23 +21,36 @@ const AppService = require('./services/AppService');
|
|||||||
const staticCache = require('./utils/staticCache');
|
const staticCache = require('./utils/staticCache');
|
||||||
const noIndex = require('./middleware/noIndex');
|
const noIndex = require('./middleware/noIndex');
|
||||||
const routes = require('./routes');
|
const routes = require('./routes');
|
||||||
|
const { getBalanceConfig } = require('./services/Config');
|
||||||
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||||
|
const { FileSources } = require('librechat-data-provider');
|
||||||
|
|
||||||
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
|
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
|
||||||
|
|
||||||
const port = Number(PORT) || 3080;
|
// Allow PORT=0 to be used for automatic free port assignment
|
||||||
|
const port = isNaN(Number(PORT)) ? 3080 : Number(PORT);
|
||||||
const host = HOST || 'localhost';
|
const host = HOST || 'localhost';
|
||||||
const trusted_proxy = Number(TRUST_PROXY) || 1; /* trust first proxy by default */
|
const trusted_proxy = Number(TRUST_PROXY) || 1; /* trust first proxy by default */
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
const startServer = async () => {
|
const startServer = async () => {
|
||||||
if (typeof Bun !== 'undefined') {
|
if (typeof Bun !== 'undefined') {
|
||||||
axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
|
axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
|
||||||
}
|
}
|
||||||
await connectDb();
|
const mongooseInstance = await connectDb();
|
||||||
|
|
||||||
|
const balanceConfig = await getBalanceConfig();
|
||||||
|
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER ?? FileSources.local);
|
||||||
|
// initialize the auth package
|
||||||
|
initAuth(mongooseInstance, balanceConfig, saveBuffer);
|
||||||
|
|
||||||
logger.info('Connected to MongoDB');
|
logger.info('Connected to MongoDB');
|
||||||
await indexSync();
|
await indexSync();
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
|
app.set('trust proxy', trusted_proxy);
|
||||||
|
|
||||||
await AppService(app);
|
await AppService(app);
|
||||||
|
|
||||||
const indexPath = path.join(app.locals.paths.dist, 'index.html');
|
const indexPath = path.join(app.locals.paths.dist, 'index.html');
|
||||||
@@ -49,28 +62,29 @@ const startServer = async () => {
|
|||||||
app.use(noIndex);
|
app.use(noIndex);
|
||||||
app.use(errorController);
|
app.use(errorController);
|
||||||
app.use(express.json({ limit: '3mb' }));
|
app.use(express.json({ limit: '3mb' }));
|
||||||
app.use(mongoSanitize());
|
|
||||||
app.use(express.urlencoded({ extended: true, limit: '3mb' }));
|
app.use(express.urlencoded({ extended: true, limit: '3mb' }));
|
||||||
app.use(staticCache(app.locals.paths.dist));
|
app.use(mongoSanitize());
|
||||||
app.use(staticCache(app.locals.paths.fonts));
|
|
||||||
app.use(staticCache(app.locals.paths.assets));
|
|
||||||
app.set('trust proxy', trusted_proxy);
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
if (!isEnabled(DISABLE_COMPRESSION)) {
|
if (!isEnabled(DISABLE_COMPRESSION)) {
|
||||||
app.use(compression());
|
app.use(compression());
|
||||||
|
} else {
|
||||||
|
console.warn('Response compression has been disabled via DISABLE_COMPRESSION.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Serve static assets with aggressive caching
|
||||||
|
app.use(staticCache(app.locals.paths.dist));
|
||||||
|
app.use(staticCache(app.locals.paths.fonts));
|
||||||
|
app.use(staticCache(app.locals.paths.assets));
|
||||||
|
|
||||||
if (!ALLOW_SOCIAL_LOGIN) {
|
if (!ALLOW_SOCIAL_LOGIN) {
|
||||||
console.warn(
|
console.warn('Social logins are disabled. Set ALLOW_SOCIAL_LOGIN=true to enable them.');
|
||||||
'Social logins are disabled. Set Environment Variable "ALLOW_SOCIAL_LOGIN" to true to enable them.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* OAUTH */
|
/* OAUTH */
|
||||||
app.use(passport.initialize());
|
app.use(passport.initialize());
|
||||||
passport.use(await jwtLogin());
|
passport.use(jwtLogin());
|
||||||
passport.use(passportLogin());
|
passport.use(passportLogin());
|
||||||
|
|
||||||
/* LDAP Auth */
|
/* LDAP Auth */
|
||||||
@@ -79,7 +93,7 @@ const startServer = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isEnabled(ALLOW_SOCIAL_LOGIN)) {
|
if (isEnabled(ALLOW_SOCIAL_LOGIN)) {
|
||||||
configureSocialLogins(app);
|
await configureSocialLogins(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use('/oauth', routes.oauth);
|
app.use('/oauth', routes.oauth);
|
||||||
@@ -110,7 +124,7 @@ const startServer = async () => {
|
|||||||
app.use('/api/agents', routes.agents);
|
app.use('/api/agents', routes.agents);
|
||||||
app.use('/api/banner', routes.banner);
|
app.use('/api/banner', routes.banner);
|
||||||
app.use('/api/bedrock', routes.bedrock);
|
app.use('/api/bedrock', routes.bedrock);
|
||||||
|
app.use('/api/memories', routes.memories);
|
||||||
app.use('/api/tags', routes.tags);
|
app.use('/api/tags', routes.tags);
|
||||||
|
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
@@ -128,7 +142,7 @@ const startServer = async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.listen(port, host, () => {
|
app.listen(port, host, () => {
|
||||||
if (host == '0.0.0.0') {
|
if (host === '0.0.0.0') {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Server listening on all interfaces at port ${port}. Use http://localhost:${port} to access it`,
|
`Server listening on all interfaces at port ${port}. Use http://localhost:${port} to access it`,
|
||||||
);
|
);
|
||||||
@@ -176,3 +190,6 @@ process.on('uncaughtException', (err) => {
|
|||||||
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// export app for easier testing purposes
|
||||||
|
module.exports = app;
|
||||||
|
|||||||
82
api/server/index.spec.js
Normal file
82
api/server/index.spec.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const request = require('supertest');
|
||||||
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Config/loadCustomConfig', () => {
|
||||||
|
return jest.fn(() => Promise.resolve({}));
|
||||||
|
});
|
||||||
|
|
||||||
|
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 '<!DOCTYPE html><html><head><title>LibreChat</title></head><body><div id="root"></div></body></html>';
|
||||||
|
}
|
||||||
|
return originalReadFileSync(filepath, options);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
// Restore original fs.readFileSync
|
||||||
|
fs.readFileSync = originalReadFileSync;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 (error) {
|
||||||
|
// 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -327,7 +327,7 @@ const handleAbortError = async (res, req, error, data) => {
|
|||||||
errorText = `{"type":"${ErrorTypes.INVALID_REQUEST}"}`;
|
errorText = `{"type":"${ErrorTypes.INVALID_REQUEST}"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error?.message?.includes('does not support \'system\'')) {
|
if (error?.message?.includes("does not support 'system'")) {
|
||||||
errorText = `{"type":"${ErrorTypes.NO_SYSTEM_MESSAGES}"}`;
|
errorText = `{"type":"${ErrorTypes.NO_SYSTEM_MESSAGES}"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
const { Keyv } = require('keyv');
|
const { Keyv } = require('keyv');
|
||||||
const uap = require('ua-parser-js');
|
const uap = require('ua-parser-js');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { ViolationTypes } = require('librechat-data-provider');
|
const { ViolationTypes } = require('librechat-data-provider');
|
||||||
const { isEnabled, removePorts } = require('~/server/utils');
|
const { isEnabled, removePorts } = require('~/server/utils');
|
||||||
const keyvMongo = require('~/cache/keyvMongo');
|
const keyvMongo = require('~/cache/keyvMongo');
|
||||||
const denyRequest = require('./denyRequest');
|
const denyRequest = require('./denyRequest');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
const { findUser } = require('~/models');
|
const { findUser } = require('~/models');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const banCache = new Keyv({ store: keyvMongo, namespace: ViolationTypes.BAN, ttl: 0 });
|
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.';
|
const message = 'Your account has been temporarily banned due to violations of our service.';
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user