Compare commits
137 Commits
feat/segme
...
feat/clien
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
014060a11b | ||
|
|
80d406b629 | ||
|
|
22035bbf95 | ||
|
|
17a164b420 | ||
|
|
5b663d0e35 | ||
|
|
c273dfc1f4 | ||
|
|
de69bcdd64 | ||
|
|
59412c2b36 | ||
|
|
0af8fba7ca | ||
|
|
d5cf83313b | ||
|
|
355da0fc2e | ||
|
|
8ba5aa6055 | ||
|
|
f1204531a8 | ||
|
|
a65e33758d | ||
|
|
0855631c54 | ||
|
|
a1e052871f | ||
|
|
ca18ada9e2 | ||
|
|
103af99879 | ||
|
|
412948e025 | ||
|
|
4da25826d9 | ||
|
|
6e7fdeb3a3 | ||
|
|
1c3f5b972d | ||
|
|
e7aa83e073 | ||
|
|
0b5155d277 | ||
|
|
86deb4d19a | ||
|
|
f741a59ec4 | ||
|
|
83477bba34 | ||
|
|
5476029bca | ||
|
|
05a0a1f7cd | ||
|
|
55f67212d5 | ||
|
|
63d0c301a0 | ||
|
|
bac6e499b7 | ||
|
|
59de92afa9 | ||
|
|
e297386cee | ||
|
|
e2b1cc607f | ||
|
|
55bda03d19 | ||
|
|
b11ba35790 | ||
|
|
8e1b00da2a | ||
|
|
39f5dd47dc | ||
|
|
16f83c6e8e | ||
|
|
b71a82d0e9 | ||
|
|
63a5902404 | ||
|
|
21c3a831c3 | ||
|
|
ae43b4eed0 | ||
|
|
2af4ca5b5c | ||
|
|
b6c7b0bc71 | ||
|
|
f8738b207c | ||
|
|
6ea1d5eab2 | ||
|
|
992911514c | ||
|
|
9289aeb2ba | ||
|
|
898d273aaf | ||
|
|
5859350bcb | ||
|
|
882cca247a | ||
|
|
0b7dd55797 | ||
|
|
9f270127d3 | ||
|
|
1380db85cb | ||
|
|
dcaa5af598 | ||
|
|
545a909953 | ||
|
|
cd436dc6a8 | ||
|
|
e75beb92b3 | ||
|
|
5251246313 | ||
|
|
26f23c6aaf | ||
|
|
1636af1f27 | ||
|
|
b050a0bf1e | ||
|
|
deb928bf80 | ||
|
|
21005b66cc | ||
|
|
3dc9e85fab | ||
|
|
ec67cf2d3a | ||
|
|
1fe977e48f | ||
|
|
01470ef9fd | ||
|
|
bef5c26bed | ||
|
|
9e03fef9db | ||
|
|
283c9cff6f | ||
|
|
0aafdc0a86 | ||
|
|
365e3bca95 | ||
|
|
a01536ddb7 | ||
|
|
8a3ff62ee6 | ||
|
|
74d8a3824c | ||
|
|
62c3f135e7 | ||
|
|
baf3b4ad08 | ||
|
|
e5d08ccdf1 | ||
|
|
5178507b1c | ||
|
|
f797e90d79 | ||
|
|
259224d986 | ||
|
|
13789ab261 | ||
|
|
faaba30af1 | ||
|
|
14660d75ae | ||
|
|
aec1777a90 | ||
|
|
90c43dd451 | ||
|
|
4c754c1190 | ||
|
|
f70e0cf849 | ||
|
|
d0c958ba33 | ||
|
|
0761e65086 | ||
|
|
0bf708915b | ||
|
|
cf59f1ab45 | ||
|
|
445e9eae85 | ||
|
|
cd9c578907 | ||
|
|
ac94c73f23 | ||
|
|
dfef7c31d2 | ||
|
|
0b1b0af741 | ||
|
|
0a169a1ff6 | ||
|
|
4b12ea327a | ||
|
|
35d8ef50f4 | ||
|
|
1dabe96404 | ||
|
|
7f8c327509 | ||
|
|
52bbac3a37 | ||
|
|
62b4f3b795 | ||
|
|
01b012a8fa | ||
|
|
418b5e9070 | ||
|
|
a9f01bb86f | ||
|
|
aeeb860fe0 | ||
|
|
e11e716807 | ||
|
|
e370a87ebe | ||
|
|
170cc340d8 | ||
|
|
f1b29ffb45 | ||
|
|
6aa4bb5a4a | ||
|
|
9f44187351 | ||
|
|
d2e1ca4c4a | ||
|
|
8e869f2274 | ||
|
|
2e1874e596 | ||
|
|
929b433662 | ||
|
|
1e4f1f780c | ||
|
|
4733f10e41 | ||
|
|
110984b48f | ||
|
|
19320f2296 | ||
|
|
8523074e87 | ||
|
|
e4531d682d | ||
|
|
4bbdc4c402 | ||
|
|
8ca4cf3d2f | ||
|
|
13a9bcdd48 | ||
|
|
4b32ec42c6 | ||
|
|
4918899c8d | ||
|
|
7e37211458 | ||
|
|
e57fc83d40 | ||
|
|
550610dba9 | ||
|
|
916cd46221 | ||
|
|
12b08183ff |
44
.env.example
44
.env.example
@@ -349,6 +349,11 @@ REGISTRATION_VIOLATION_SCORE=1
|
|||||||
CONCURRENT_VIOLATION_SCORE=1
|
CONCURRENT_VIOLATION_SCORE=1
|
||||||
MESSAGE_VIOLATION_SCORE=1
|
MESSAGE_VIOLATION_SCORE=1
|
||||||
NON_BROWSER_VIOLATION_SCORE=20
|
NON_BROWSER_VIOLATION_SCORE=20
|
||||||
|
TTS_VIOLATION_SCORE=0
|
||||||
|
STT_VIOLATION_SCORE=0
|
||||||
|
FORK_VIOLATION_SCORE=0
|
||||||
|
IMPORT_VIOLATION_SCORE=0
|
||||||
|
FILE_UPLOAD_VIOLATION_SCORE=0
|
||||||
|
|
||||||
LOGIN_MAX=7
|
LOGIN_MAX=7
|
||||||
LOGIN_WINDOW=5
|
LOGIN_WINDOW=5
|
||||||
@@ -575,6 +580,10 @@ ALLOW_SHARED_LINKS_PUBLIC=true
|
|||||||
# If you have another service in front of your LibreChat doing compression, disable express based compression here
|
# If you have another service in front of your LibreChat doing compression, disable express based compression here
|
||||||
# DISABLE_COMPRESSION=true
|
# DISABLE_COMPRESSION=true
|
||||||
|
|
||||||
|
# If you have gzipped version of uploaded image images in the same folder, this will enable gzip scan and serving of these images
|
||||||
|
# Note: The images folder will be scanned on startup and a ma kept in memory. Be careful for large number of images.
|
||||||
|
# ENABLE_IMAGE_OUTPUT_GZIP_SCAN=true
|
||||||
|
|
||||||
#===================================================#
|
#===================================================#
|
||||||
# UI #
|
# UI #
|
||||||
#===================================================#
|
#===================================================#
|
||||||
@@ -592,11 +601,40 @@ HELP_AND_FAQ_URL=https://librechat.ai
|
|||||||
# REDIS Options #
|
# REDIS Options #
|
||||||
#===============#
|
#===============#
|
||||||
|
|
||||||
# REDIS_URI=10.10.10.10:6379
|
# Enable Redis for caching and session storage
|
||||||
# USE_REDIS=true
|
# USE_REDIS=true
|
||||||
|
|
||||||
# USE_REDIS_CLUSTER=true
|
# Single Redis instance
|
||||||
# REDIS_CA=/path/to/ca.crt
|
# REDIS_URI=redis://127.0.0.1:6379
|
||||||
|
|
||||||
|
# Redis cluster (multiple nodes)
|
||||||
|
# REDIS_URI=redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
|
||||||
|
|
||||||
|
# Redis with TLS/SSL encryption and CA certificate
|
||||||
|
# REDIS_URI=rediss://127.0.0.1:6380
|
||||||
|
# REDIS_CA=/path/to/ca-cert.pem
|
||||||
|
|
||||||
|
# Redis authentication (if required)
|
||||||
|
# REDIS_USERNAME=your_redis_username
|
||||||
|
# REDIS_PASSWORD=your_redis_password
|
||||||
|
|
||||||
|
# Redis key prefix configuration
|
||||||
|
# Use environment variable name for dynamic prefix (recommended for cloud deployments)
|
||||||
|
# REDIS_KEY_PREFIX_VAR=K_REVISION
|
||||||
|
# Or use static prefix directly
|
||||||
|
# REDIS_KEY_PREFIX=librechat
|
||||||
|
|
||||||
|
# Redis connection limits
|
||||||
|
# REDIS_MAX_LISTENERS=40
|
||||||
|
|
||||||
|
# Redis ping interval in seconds (0 = disabled, >0 = enabled)
|
||||||
|
# When set to a positive integer, Redis clients will ping the server at this interval to keep connections alive
|
||||||
|
# When unset or 0, no pinging is performed (recommended for most use cases)
|
||||||
|
# REDIS_PING_INTERVAL=300
|
||||||
|
|
||||||
|
# Force specific cache namespaces to use in-memory storage even when Redis is enabled
|
||||||
|
# Comma-separated list of CacheKeys (e.g., STATIC_CONFIG,ROLES,MESSAGES)
|
||||||
|
# FORCED_IN_MEMORY_CACHE_NAMESPACES=STATIC_CONFIG,ROLES
|
||||||
|
|
||||||
#==================================================#
|
#==================================================#
|
||||||
# Others #
|
# Others #
|
||||||
|
|||||||
2
.github/workflows/backend-review.yml
vendored
2
.github/workflows/backend-review.yml
vendored
@@ -7,7 +7,7 @@ on:
|
|||||||
- release/*
|
- release/*
|
||||||
paths:
|
paths:
|
||||||
- 'api/**'
|
- 'api/**'
|
||||||
- 'packages/api/**'
|
- 'packages/**'
|
||||||
jobs:
|
jobs:
|
||||||
tests_Backend:
|
tests_Backend:
|
||||||
name: Run Backend unit tests
|
name: Run Backend unit tests
|
||||||
|
|||||||
58
.github/workflows/client.yml
vendored
Normal file
58
.github/workflows/client.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
name: Publish `@librechat/client` to NPM
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'packages/client/package.json'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
reason:
|
||||||
|
description: 'Reason for manual trigger'
|
||||||
|
required: false
|
||||||
|
default: 'Manual publish requested'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20.x'
|
||||||
|
|
||||||
|
- name: Install client dependencies
|
||||||
|
run: cd packages/client && npm ci
|
||||||
|
|
||||||
|
- name: Build client
|
||||||
|
run: cd packages/client && npm run build
|
||||||
|
|
||||||
|
- name: Set up npm authentication
|
||||||
|
run: echo "//registry.npmjs.org/:_authToken=${{ secrets.PUBLISH_NPM_TOKEN }}" > ~/.npmrc
|
||||||
|
|
||||||
|
- name: Check version change
|
||||||
|
id: check
|
||||||
|
working-directory: packages/client
|
||||||
|
run: |
|
||||||
|
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||||
|
PUBLISHED_VERSION=$(npm view @librechat/client version 2>/dev/null || echo "0.0.0")
|
||||||
|
if [ "$PACKAGE_VERSION" = "$PUBLISHED_VERSION" ]; then
|
||||||
|
echo "No version change, skipping publish"
|
||||||
|
echo "skip=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "Version changed, proceeding with publish"
|
||||||
|
echo "skip=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Pack package
|
||||||
|
if: steps.check.outputs.skip != 'true'
|
||||||
|
working-directory: packages/client
|
||||||
|
run: npm pack
|
||||||
|
|
||||||
|
- name: Publish
|
||||||
|
if: steps.check.outputs.skip != 'true'
|
||||||
|
working-directory: packages/client
|
||||||
|
run: npm publish *.tgz --access public
|
||||||
2
.github/workflows/frontend-review.yml
vendored
2
.github/workflows/frontend-review.yml
vendored
@@ -8,7 +8,7 @@ on:
|
|||||||
- release/*
|
- release/*
|
||||||
paths:
|
paths:
|
||||||
- 'client/**'
|
- 'client/**'
|
||||||
- 'packages/**'
|
- 'packages/data-provider/**'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests_frontend_ubuntu:
|
tests_frontend_ubuntu:
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
name: Generate Release Changelog PR
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*.*.*'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
generate-release-changelog-pr:
|
|
||||||
permissions:
|
|
||||||
contents: write # Needed for pushing commits and creating branches.
|
|
||||||
pull-requests: write
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
# 1. Checkout the repository (with full history).
|
|
||||||
- name: Checkout Repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
# 2. Generate the release changelog using our custom configuration.
|
|
||||||
- name: Generate Release Changelog
|
|
||||||
id: generate_release
|
|
||||||
uses: mikepenz/release-changelog-builder-action@v5.1.0
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
configuration: ".github/configuration-release.json"
|
|
||||||
owner: ${{ github.repository_owner }}
|
|
||||||
repo: ${{ github.event.repository.name }}
|
|
||||||
outputFile: CHANGELOG-release.md
|
|
||||||
|
|
||||||
# 3. Update the main CHANGELOG.md:
|
|
||||||
# - If it doesn't exist, create it with a basic header.
|
|
||||||
# - Remove the "Unreleased" section (if present).
|
|
||||||
# - Prepend the new release changelog above previous releases.
|
|
||||||
# - Remove all temporary files before committing.
|
|
||||||
- name: Update CHANGELOG.md
|
|
||||||
run: |
|
|
||||||
# Determine the release tag, e.g. "v1.2.3"
|
|
||||||
TAG=${GITHUB_REF##*/}
|
|
||||||
echo "Using release tag: $TAG"
|
|
||||||
|
|
||||||
# Ensure CHANGELOG.md exists; if not, create a basic header.
|
|
||||||
if [ ! -f CHANGELOG.md ]; then
|
|
||||||
echo "# Changelog" > CHANGELOG.md
|
|
||||||
echo "" >> CHANGELOG.md
|
|
||||||
echo "All notable changes to this project will be documented in this file." >> CHANGELOG.md
|
|
||||||
echo "" >> CHANGELOG.md
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Updating CHANGELOG.md…"
|
|
||||||
|
|
||||||
# Remove the "Unreleased" section (from "## [Unreleased]" until the first occurrence of '---') if it exists.
|
|
||||||
if grep -q "^## \[Unreleased\]" CHANGELOG.md; then
|
|
||||||
awk '/^## \[Unreleased\]/{flag=1} flag && /^---/{flag=0; next} !flag' CHANGELOG.md > CHANGELOG.cleaned
|
|
||||||
else
|
|
||||||
cp CHANGELOG.md CHANGELOG.cleaned
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Split the cleaned file into:
|
|
||||||
# - header.md: content before the first release header ("## [v...").
|
|
||||||
# - tail.md: content from the first release header onward.
|
|
||||||
awk '/^## \[v/{exit} {print}' CHANGELOG.cleaned > header.md
|
|
||||||
awk 'f{print} /^## \[v/{f=1; print}' CHANGELOG.cleaned > tail.md
|
|
||||||
|
|
||||||
# Combine header, the new release changelog, and the tail.
|
|
||||||
echo "Combining updated changelog parts..."
|
|
||||||
cat header.md CHANGELOG-release.md > CHANGELOG.md.new
|
|
||||||
echo "" >> CHANGELOG.md.new
|
|
||||||
cat tail.md >> CHANGELOG.md.new
|
|
||||||
|
|
||||||
mv CHANGELOG.md.new CHANGELOG.md
|
|
||||||
|
|
||||||
# Remove temporary files.
|
|
||||||
rm -f CHANGELOG.cleaned header.md tail.md CHANGELOG-release.md
|
|
||||||
|
|
||||||
echo "Final CHANGELOG.md content:"
|
|
||||||
cat CHANGELOG.md
|
|
||||||
|
|
||||||
# 4. Create (or update) the Pull Request with the updated CHANGELOG.md.
|
|
||||||
- name: Create Pull Request
|
|
||||||
uses: peter-evans/create-pull-request@v7
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
sign-commits: true
|
|
||||||
commit-message: "chore: update CHANGELOG for release ${{ github.ref_name }}"
|
|
||||||
base: main
|
|
||||||
branch: "changelog/${{ github.ref_name }}"
|
|
||||||
reviewers: danny-avila
|
|
||||||
title: "📜 docs: Changelog for release ${{ github.ref_name }}"
|
|
||||||
body: |
|
|
||||||
**Description**:
|
|
||||||
- This PR updates the CHANGELOG.md by removing the "Unreleased" section and adding new release notes for release ${{ github.ref_name }} above previous releases.
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
name: Generate Unreleased Changelog PR
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 0 * * 1" # Runs every Monday at 00:00 UTC
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
generate-unreleased-changelog-pr:
|
|
||||||
permissions:
|
|
||||||
contents: write # Needed for pushing commits and creating branches.
|
|
||||||
pull-requests: write
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
# 1. Checkout the repository on main.
|
|
||||||
- name: Checkout Repository on Main
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: main
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
# 4. Get the latest version tag.
|
|
||||||
- name: Get Latest Tag
|
|
||||||
id: get_latest_tag
|
|
||||||
run: |
|
|
||||||
LATEST_TAG=$(git describe --tags $(git rev-list --tags --max-count=1) || echo "none")
|
|
||||||
echo "Latest tag: $LATEST_TAG"
|
|
||||||
echo "tag=$LATEST_TAG" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
# 5. Generate the Unreleased changelog.
|
|
||||||
- name: Generate Unreleased Changelog
|
|
||||||
id: generate_unreleased
|
|
||||||
uses: mikepenz/release-changelog-builder-action@v5.1.0
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
configuration: ".github/configuration-unreleased.json"
|
|
||||||
owner: ${{ github.repository_owner }}
|
|
||||||
repo: ${{ github.event.repository.name }}
|
|
||||||
outputFile: CHANGELOG-unreleased.md
|
|
||||||
fromTag: ${{ steps.get_latest_tag.outputs.tag }}
|
|
||||||
toTag: main
|
|
||||||
|
|
||||||
# 7. Update CHANGELOG.md with the new Unreleased section.
|
|
||||||
- name: Update CHANGELOG.md
|
|
||||||
id: update_changelog
|
|
||||||
run: |
|
|
||||||
# Create CHANGELOG.md if it doesn't exist.
|
|
||||||
if [ ! -f CHANGELOG.md ]; then
|
|
||||||
echo "# Changelog" > CHANGELOG.md
|
|
||||||
echo "" >> CHANGELOG.md
|
|
||||||
echo "All notable changes to this project will be documented in this file." >> CHANGELOG.md
|
|
||||||
echo "" >> CHANGELOG.md
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Updating CHANGELOG.md…"
|
|
||||||
|
|
||||||
# Extract content before the "## [Unreleased]" (or first version header if missing).
|
|
||||||
if grep -q "^## \[Unreleased\]" CHANGELOG.md; then
|
|
||||||
awk '/^## \[Unreleased\]/{exit} {print}' CHANGELOG.md > CHANGELOG_TMP.md
|
|
||||||
else
|
|
||||||
awk '/^## \[v/{exit} {print}' CHANGELOG.md > CHANGELOG_TMP.md
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Append the generated Unreleased changelog.
|
|
||||||
echo "" >> CHANGELOG_TMP.md
|
|
||||||
cat CHANGELOG-unreleased.md >> CHANGELOG_TMP.md
|
|
||||||
echo "" >> CHANGELOG_TMP.md
|
|
||||||
|
|
||||||
# Append the remainder of the original changelog (starting from the first version header).
|
|
||||||
awk 'f{print} /^## \[v/{f=1; print}' CHANGELOG.md >> CHANGELOG_TMP.md
|
|
||||||
|
|
||||||
# Replace the old file with the updated file.
|
|
||||||
mv CHANGELOG_TMP.md CHANGELOG.md
|
|
||||||
|
|
||||||
# Remove the temporary generated file.
|
|
||||||
rm -f CHANGELOG-unreleased.md
|
|
||||||
|
|
||||||
echo "Final CHANGELOG.md:"
|
|
||||||
cat CHANGELOG.md
|
|
||||||
|
|
||||||
# 8. Check if CHANGELOG.md has any updates.
|
|
||||||
- name: Check for CHANGELOG.md changes
|
|
||||||
id: changelog_changes
|
|
||||||
run: |
|
|
||||||
if git diff --quiet CHANGELOG.md; then
|
|
||||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 9. Create (or update) the Pull Request only if there are changes.
|
|
||||||
- name: Create Pull Request
|
|
||||||
if: steps.changelog_changes.outputs.has_changes == 'true'
|
|
||||||
uses: peter-evans/create-pull-request@v7
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
base: main
|
|
||||||
branch: "changelog/unreleased-update"
|
|
||||||
sign-commits: true
|
|
||||||
commit-message: "action: update Unreleased changelog"
|
|
||||||
title: "📜 docs: Unreleased Changelog"
|
|
||||||
body: |
|
|
||||||
**Description**:
|
|
||||||
- This PR updates the Unreleased section in CHANGELOG.md.
|
|
||||||
- It compares the current main branch with the latest version tag (determined as ${{ steps.get_latest_tag.outputs.tag }}),
|
|
||||||
regenerates the Unreleased changelog, removes any old Unreleased block, and inserts the new content.
|
|
||||||
3
.github/workflows/i18n-unused-keys.yml
vendored
3
.github/workflows/i18n-unused-keys.yml
vendored
@@ -6,6 +6,7 @@ on:
|
|||||||
- "client/src/**"
|
- "client/src/**"
|
||||||
- "api/**"
|
- "api/**"
|
||||||
- "packages/data-provider/src/**"
|
- "packages/data-provider/src/**"
|
||||||
|
- "packages/client/**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
detect-unused-i18n-keys:
|
detect-unused-i18n-keys:
|
||||||
@@ -23,7 +24,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" "packages/data-provider/src")
|
SOURCE_DIRS=("client/src" "api" "packages/data-provider/src" "packages/client")
|
||||||
|
|
||||||
# Check if translation file exists
|
# Check if translation file exists
|
||||||
if [[ ! -f "$I18N_FILE" ]]; then
|
if [[ ! -f "$I18N_FILE" ]]; then
|
||||||
|
|||||||
101
.github/workflows/unused-packages.yml
vendored
101
.github/workflows/unused-packages.yml
vendored
@@ -7,6 +7,7 @@ on:
|
|||||||
- 'package-lock.json'
|
- 'package-lock.json'
|
||||||
- 'client/**'
|
- 'client/**'
|
||||||
- 'api/**'
|
- 'api/**'
|
||||||
|
- 'packages/client/**'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
detect-unused-packages:
|
detect-unused-packages:
|
||||||
@@ -28,7 +29,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Validate JSON files
|
- name: Validate JSON files
|
||||||
run: |
|
run: |
|
||||||
for FILE in package.json client/package.json api/package.json; do
|
for FILE in package.json client/package.json api/package.json packages/client/package.json; do
|
||||||
if [[ -f "$FILE" ]]; then
|
if [[ -f "$FILE" ]]; then
|
||||||
jq empty "$FILE" || (echo "::error title=Invalid JSON::$FILE is invalid" && exit 1)
|
jq empty "$FILE" || (echo "::error title=Invalid JSON::$FILE is invalid" && exit 1)
|
||||||
fi
|
fi
|
||||||
@@ -63,12 +64,31 @@ jobs:
|
|||||||
local folder=$1
|
local folder=$1
|
||||||
local output_file=$2
|
local output_file=$2
|
||||||
if [[ -d "$folder" ]]; then
|
if [[ -d "$folder" ]]; then
|
||||||
grep -rEho "require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)" "$folder" --include=\*.{js,ts,mjs,cjs} | \
|
# Extract require() statements
|
||||||
|
grep -rEho "require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)" "$folder" --include=\*.{js,ts,tsx,jsx,mjs,cjs} | \
|
||||||
sed -E "s/require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)/\1/" > "$output_file"
|
sed -E "s/require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)/\1/" > "$output_file"
|
||||||
|
|
||||||
grep -rEho "import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" --include=\*.{js,ts,mjs,cjs} | \
|
# Extract ES6 imports - various patterns
|
||||||
|
# import x from 'module'
|
||||||
|
grep -rEho "import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" --include=\*.{js,ts,tsx,jsx,mjs,cjs} | \
|
||||||
sed -E "s/import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file"
|
sed -E "s/import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file"
|
||||||
|
|
||||||
|
# import 'module' (side-effect imports)
|
||||||
|
grep -rEho "import ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" --include=\*.{js,ts,tsx,jsx,mjs,cjs} | \
|
||||||
|
sed -E "s/import ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file"
|
||||||
|
|
||||||
|
# export { x } from 'module' or export * from 'module'
|
||||||
|
grep -rEho "export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" --include=\*.{js,ts,tsx,jsx,mjs,cjs} | \
|
||||||
|
sed -E "s/export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file"
|
||||||
|
|
||||||
|
# import type { x } from 'module' (TypeScript)
|
||||||
|
grep -rEho "import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" --include=\*.{ts,tsx} | \
|
||||||
|
sed -E "s/import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file"
|
||||||
|
|
||||||
|
# Remove subpath imports but keep the base package
|
||||||
|
# e.g., '@tanstack/react-query/devtools' becomes '@tanstack/react-query'
|
||||||
|
sed -i -E 's|^(@?[a-zA-Z0-9-]+(/[a-zA-Z0-9-]+)?)/.*|\1|' "$output_file"
|
||||||
|
|
||||||
sort -u "$output_file" -o "$output_file"
|
sort -u "$output_file" -o "$output_file"
|
||||||
else
|
else
|
||||||
touch "$output_file"
|
touch "$output_file"
|
||||||
@@ -78,13 +98,80 @@ jobs:
|
|||||||
extract_deps_from_code "." root_used_code.txt
|
extract_deps_from_code "." root_used_code.txt
|
||||||
extract_deps_from_code "client" client_used_code.txt
|
extract_deps_from_code "client" client_used_code.txt
|
||||||
extract_deps_from_code "api" api_used_code.txt
|
extract_deps_from_code "api" api_used_code.txt
|
||||||
|
|
||||||
|
# Extract dependencies used by @librechat/client package
|
||||||
|
extract_deps_from_code "packages/client" packages_client_used_code.txt
|
||||||
|
|
||||||
|
- name: Get @librechat/client dependencies
|
||||||
|
id: get-librechat-client-deps
|
||||||
|
run: |
|
||||||
|
if [[ -f "packages/client/package.json" ]]; then
|
||||||
|
# Get all dependencies from @librechat/client (dependencies, devDependencies, and peerDependencies)
|
||||||
|
DEPS=$(jq -r '.dependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "")
|
||||||
|
DEV_DEPS=$(jq -r '.devDependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "")
|
||||||
|
PEER_DEPS=$(jq -r '.peerDependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
# Combine all dependencies
|
||||||
|
echo "$DEPS" > librechat_client_deps.txt
|
||||||
|
echo "$DEV_DEPS" >> librechat_client_deps.txt
|
||||||
|
echo "$PEER_DEPS" >> librechat_client_deps.txt
|
||||||
|
|
||||||
|
# Also include dependencies that are imported in packages/client
|
||||||
|
cat packages_client_used_code.txt >> librechat_client_deps.txt
|
||||||
|
|
||||||
|
# Remove empty lines and sort
|
||||||
|
grep -v '^$' librechat_client_deps.txt | sort -u > temp_deps.txt
|
||||||
|
mv temp_deps.txt librechat_client_deps.txt
|
||||||
|
else
|
||||||
|
touch librechat_client_deps.txt
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Extract Workspace Dependencies
|
||||||
|
id: extract-workspace-deps
|
||||||
|
run: |
|
||||||
|
# Function to get dependencies from a workspace package that are used by another package
|
||||||
|
get_workspace_package_deps() {
|
||||||
|
local package_json=$1
|
||||||
|
local output_file=$2
|
||||||
|
|
||||||
|
# Get all workspace dependencies (starting with @librechat/)
|
||||||
|
if [[ -f "$package_json" ]]; then
|
||||||
|
local workspace_deps=$(jq -r '.dependencies // {} | to_entries[] | select(.key | startswith("@librechat/")) | .key' "$package_json" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
# For each workspace dependency, get its dependencies
|
||||||
|
for dep in $workspace_deps; do
|
||||||
|
# Convert @librechat/api to packages/api
|
||||||
|
local workspace_path=$(echo "$dep" | sed 's/@librechat\//packages\//')
|
||||||
|
local workspace_package_json="${workspace_path}/package.json"
|
||||||
|
|
||||||
|
if [[ -f "$workspace_package_json" ]]; then
|
||||||
|
# Extract all dependencies from the workspace package
|
||||||
|
jq -r '.dependencies // {} | keys[]' "$workspace_package_json" 2>/dev/null >> "$output_file"
|
||||||
|
# Also extract peerDependencies
|
||||||
|
jq -r '.peerDependencies // {} | keys[]' "$workspace_package_json" 2>/dev/null >> "$output_file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$output_file" ]]; then
|
||||||
|
sort -u "$output_file" -o "$output_file"
|
||||||
|
else
|
||||||
|
touch "$output_file"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get workspace dependencies for each package
|
||||||
|
get_workspace_package_deps "package.json" root_workspace_deps.txt
|
||||||
|
get_workspace_package_deps "client/package.json" client_workspace_deps.txt
|
||||||
|
get_workspace_package_deps "api/package.json" api_workspace_deps.txt
|
||||||
|
|
||||||
- name: Run depcheck for root package.json
|
- name: Run depcheck for root package.json
|
||||||
id: check-root
|
id: check-root
|
||||||
run: |
|
run: |
|
||||||
if [[ -f "package.json" ]]; then
|
if [[ -f "package.json" ]]; then
|
||||||
UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
|
UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
|
||||||
UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat root_used_deps.txt root_used_code.txt | sort) || echo "")
|
# Exclude dependencies used in scripts, code, and workspace packages
|
||||||
|
UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat root_used_deps.txt root_used_code.txt root_workspace_deps.txt | sort) || echo "")
|
||||||
echo "ROOT_UNUSED<<EOF" >> $GITHUB_ENV
|
echo "ROOT_UNUSED<<EOF" >> $GITHUB_ENV
|
||||||
echo "$UNUSED" >> $GITHUB_ENV
|
echo "$UNUSED" >> $GITHUB_ENV
|
||||||
echo "EOF" >> $GITHUB_ENV
|
echo "EOF" >> $GITHUB_ENV
|
||||||
@@ -97,7 +184,8 @@ jobs:
|
|||||||
chmod -R 755 client
|
chmod -R 755 client
|
||||||
cd client
|
cd client
|
||||||
UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
|
UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
|
||||||
UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../client_used_deps.txt ../client_used_code.txt | sort) || echo "")
|
# Exclude dependencies used in scripts, code, and workspace packages
|
||||||
|
UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../client_used_deps.txt ../client_used_code.txt ../client_workspace_deps.txt | sort) || echo "")
|
||||||
# Filter out false positives
|
# Filter out false positives
|
||||||
UNUSED=$(echo "$UNUSED" | grep -v "^micromark-extension-llm-math$" || echo "")
|
UNUSED=$(echo "$UNUSED" | grep -v "^micromark-extension-llm-math$" || echo "")
|
||||||
echo "CLIENT_UNUSED<<EOF" >> $GITHUB_ENV
|
echo "CLIENT_UNUSED<<EOF" >> $GITHUB_ENV
|
||||||
@@ -113,7 +201,8 @@ jobs:
|
|||||||
chmod -R 755 api
|
chmod -R 755 api
|
||||||
cd api
|
cd api
|
||||||
UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
|
UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
|
||||||
UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../api_used_deps.txt ../api_used_code.txt | sort) || echo "")
|
# Exclude dependencies used in scripts, code, and workspace packages
|
||||||
|
UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../api_used_deps.txt ../api_used_code.txt ../api_workspace_deps.txt | sort) || echo "")
|
||||||
echo "API_UNUSED<<EOF" >> $GITHUB_ENV
|
echo "API_UNUSED<<EOF" >> $GITHUB_ENV
|
||||||
echo "$UNUSED" >> $GITHUB_ENV
|
echo "$UNUSED" >> $GITHUB_ENV
|
||||||
echo "EOF" >> $GITHUB_ENV
|
echo "EOF" >> $GITHUB_ENV
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -125,3 +125,12 @@ helm/**/.values.yaml
|
|||||||
|
|
||||||
# SAML Idp cert
|
# SAML Idp cert
|
||||||
*.cert
|
*.cert
|
||||||
|
|
||||||
|
# AI Assistants
|
||||||
|
/.claude/
|
||||||
|
/.cursor/
|
||||||
|
/.copilot/
|
||||||
|
/.aider/
|
||||||
|
/.openai/
|
||||||
|
/.tabnine/
|
||||||
|
/.codeium
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# v0.7.9-rc1
|
# v0.7.9
|
||||||
|
|
||||||
# Base node image
|
# Base node image
|
||||||
FROM node:20-alpine AS node
|
FROM node:20-alpine AS node
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Dockerfile.multi
|
# Dockerfile.multi
|
||||||
# v0.7.9-rc1
|
# v0.7.9
|
||||||
|
|
||||||
# Base for all builds
|
# Base for all builds
|
||||||
FROM node:20-alpine AS base-min
|
FROM node:20-alpine AS base-min
|
||||||
|
|||||||
@@ -108,12 +108,15 @@ class BaseClient {
|
|||||||
/**
|
/**
|
||||||
* Abstract method to record token usage. Subclasses must implement this method.
|
* Abstract method to record token usage. Subclasses must implement this method.
|
||||||
* If a correction to the token usage is needed, the method should return an object with the corrected token counts.
|
* If a correction to the token usage is needed, the method should return an object with the corrected token counts.
|
||||||
|
* Should only be used if `recordCollectedUsage` was not used instead.
|
||||||
|
* @param {string} [model]
|
||||||
* @param {number} promptTokens
|
* @param {number} promptTokens
|
||||||
* @param {number} completionTokens
|
* @param {number} completionTokens
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async recordTokenUsage({ promptTokens, completionTokens }) {
|
async recordTokenUsage({ model, promptTokens, completionTokens }) {
|
||||||
logger.debug('[BaseClient] `recordTokenUsage` not implemented.', {
|
logger.debug('[BaseClient] `recordTokenUsage` not implemented.', {
|
||||||
|
model,
|
||||||
promptTokens,
|
promptTokens,
|
||||||
completionTokens,
|
completionTokens,
|
||||||
});
|
});
|
||||||
@@ -197,6 +200,10 @@ class BaseClient {
|
|||||||
this.currentMessages[this.currentMessages.length - 1].messageId = head;
|
this.currentMessages[this.currentMessages.length - 1].messageId = head;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts.isRegenerate && responseMessageId.endsWith('_')) {
|
||||||
|
responseMessageId = crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
this.responseMessageId = responseMessageId;
|
this.responseMessageId = responseMessageId;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -737,9 +744,13 @@ class BaseClient {
|
|||||||
} else {
|
} else {
|
||||||
responseMessage.tokenCount = this.getTokenCountForResponse(responseMessage);
|
responseMessage.tokenCount = this.getTokenCountForResponse(responseMessage);
|
||||||
completionTokens = responseMessage.tokenCount;
|
completionTokens = responseMessage.tokenCount;
|
||||||
|
await this.recordTokenUsage({
|
||||||
|
usage,
|
||||||
|
promptTokens,
|
||||||
|
completionTokens,
|
||||||
|
model: responseMessage.model,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.recordTokenUsage({ promptTokens, completionTokens, usage });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userMessagePromise) {
|
if (userMessagePromise) {
|
||||||
|
|||||||
@@ -237,41 +237,9 @@ const formatAgentMessages = (payload) => {
|
|||||||
return messages;
|
return messages;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats an array of messages for LangChain, making sure all content fields are strings
|
|
||||||
* @param {Array<(HumanMessage|AIMessage|SystemMessage|ToolMessage)>} payload - The array of messages to format.
|
|
||||||
* @returns {Array<(HumanMessage|AIMessage|SystemMessage|ToolMessage)>} - The array of formatted LangChain messages, including ToolMessages for tool calls.
|
|
||||||
*/
|
|
||||||
const formatContentStrings = (payload) => {
|
|
||||||
const messages = [];
|
|
||||||
|
|
||||||
for (const message of payload) {
|
|
||||||
if (typeof message.content === 'string') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(message.content)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reduce text types to a single string, ignore all other types
|
|
||||||
const content = message.content.reduce((acc, curr) => {
|
|
||||||
if (curr.type === ContentTypes.TEXT) {
|
|
||||||
return `${acc}${curr[ContentTypes.TEXT]}\n`;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, '');
|
|
||||||
|
|
||||||
message.content = content.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
formatMessage,
|
formatMessage,
|
||||||
formatFromLangChain,
|
formatFromLangChain,
|
||||||
formatAgentMessages,
|
formatAgentMessages,
|
||||||
formatContentStrings,
|
|
||||||
formatLangChainMessages,
|
formatLangChainMessages,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -422,6 +422,46 @@ describe('BaseClient', () => {
|
|||||||
expect(response).toEqual(expectedResult);
|
expect(response).toEqual(expectedResult);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should replace responseMessageId with new UUID when isRegenerate is true and messageId ends with underscore', async () => {
|
||||||
|
const mockCrypto = require('crypto');
|
||||||
|
const newUUID = 'new-uuid-1234';
|
||||||
|
jest.spyOn(mockCrypto, 'randomUUID').mockReturnValue(newUUID);
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
isRegenerate: true,
|
||||||
|
responseMessageId: 'existing-message-id_',
|
||||||
|
};
|
||||||
|
|
||||||
|
await TestClient.setMessageOptions(opts);
|
||||||
|
|
||||||
|
expect(TestClient.responseMessageId).toBe(newUUID);
|
||||||
|
expect(TestClient.responseMessageId).not.toBe('existing-message-id_');
|
||||||
|
|
||||||
|
mockCrypto.randomUUID.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not replace responseMessageId when isRegenerate is false', async () => {
|
||||||
|
const opts = {
|
||||||
|
isRegenerate: false,
|
||||||
|
responseMessageId: 'existing-message-id_',
|
||||||
|
};
|
||||||
|
|
||||||
|
await TestClient.setMessageOptions(opts);
|
||||||
|
|
||||||
|
expect(TestClient.responseMessageId).toBe('existing-message-id_');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not replace responseMessageId when it does not end with underscore', async () => {
|
||||||
|
const opts = {
|
||||||
|
isRegenerate: true,
|
||||||
|
responseMessageId: 'existing-message-id',
|
||||||
|
};
|
||||||
|
|
||||||
|
await TestClient.setMessageOptions(opts);
|
||||||
|
|
||||||
|
expect(TestClient.responseMessageId).toBe('existing-message-id');
|
||||||
|
});
|
||||||
|
|
||||||
test('sendMessage should work with provided conversationId and parentMessageId', async () => {
|
test('sendMessage should work with provided conversationId and parentMessageId', async () => {
|
||||||
const userMessage = 'Second message in the conversation';
|
const userMessage = 'Second message in the conversation';
|
||||||
const opts = {
|
const opts = {
|
||||||
|
|||||||
@@ -11,17 +11,25 @@ const { getFiles } = require('~/models/File');
|
|||||||
* @param {Object} options
|
* @param {Object} options
|
||||||
* @param {ServerRequest} options.req
|
* @param {ServerRequest} options.req
|
||||||
* @param {Agent['tool_resources']} options.tool_resources
|
* @param {Agent['tool_resources']} options.tool_resources
|
||||||
|
* @param {string} [options.agentId] - The agent ID for file access control
|
||||||
* @returns {Promise<{
|
* @returns {Promise<{
|
||||||
* files: Array<{ file_id: string; filename: string }>,
|
* files: Array<{ file_id: string; filename: string }>,
|
||||||
* toolContext: string
|
* toolContext: string
|
||||||
* }>}
|
* }>}
|
||||||
*/
|
*/
|
||||||
const primeFiles = async (options) => {
|
const primeFiles = async (options) => {
|
||||||
const { tool_resources } = options;
|
const { tool_resources, req, agentId } = options;
|
||||||
const file_ids = tool_resources?.[EToolResources.file_search]?.file_ids ?? [];
|
const file_ids = tool_resources?.[EToolResources.file_search]?.file_ids ?? [];
|
||||||
const agentResourceIds = new Set(file_ids);
|
const agentResourceIds = new Set(file_ids);
|
||||||
const resourceFiles = tool_resources?.[EToolResources.file_search]?.files ?? [];
|
const resourceFiles = tool_resources?.[EToolResources.file_search]?.files ?? [];
|
||||||
const dbFiles = ((await getFiles({ file_id: { $in: file_ids } })) ?? []).concat(resourceFiles);
|
const dbFiles = (
|
||||||
|
(await getFiles(
|
||||||
|
{ file_id: { $in: file_ids } },
|
||||||
|
null,
|
||||||
|
{ text: 0 },
|
||||||
|
{ userId: req?.user?.id, agentId },
|
||||||
|
)) ?? []
|
||||||
|
).concat(resourceFiles);
|
||||||
|
|
||||||
let toolContext = `- Note: Semantic search is available through the ${Tools.file_search} tool but no files are currently loaded. Request the user to upload documents to search through.`;
|
let toolContext = `- Note: Semantic search is available through the ${Tools.file_search} tool but no files are currently loaded. Request the user to upload documents to search through.`;
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
const { mcpToolPattern } = require('@librechat/api');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
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 { mcpToolPattern, loadWebSearchAuth } = require('@librechat/api');
|
||||||
const { EnvVar, createCodeExecutionTool, createSearchTool } = require('@librechat/agents');
|
const { EnvVar, createCodeExecutionTool, createSearchTool } = require('@librechat/agents');
|
||||||
const {
|
const { Tools, EToolResources, replaceSpecialVars } = require('librechat-data-provider');
|
||||||
Tools,
|
|
||||||
EToolResources,
|
|
||||||
loadWebSearchAuth,
|
|
||||||
replaceSpecialVars,
|
|
||||||
} = require('librechat-data-provider');
|
|
||||||
const {
|
const {
|
||||||
availableTools,
|
availableTools,
|
||||||
manifestToolMap,
|
manifestToolMap,
|
||||||
@@ -235,7 +230,7 @@ const loadTools = async ({
|
|||||||
|
|
||||||
/** @type {Record<string, string>} */
|
/** @type {Record<string, string>} */
|
||||||
const toolContextMap = {};
|
const toolContextMap = {};
|
||||||
const appTools = (await getCachedTools({ includeGlobal: true })) ?? {};
|
const cachedTools = (await getCachedTools({ userId: user, includeGlobal: true })) ?? {};
|
||||||
|
|
||||||
for (const tool of tools) {
|
for (const tool of tools) {
|
||||||
if (tool === Tools.execute_code) {
|
if (tool === Tools.execute_code) {
|
||||||
@@ -245,7 +240,13 @@ const loadTools = async ({
|
|||||||
authFields: [EnvVar.CODE_API_KEY],
|
authFields: [EnvVar.CODE_API_KEY],
|
||||||
});
|
});
|
||||||
const codeApiKey = authValues[EnvVar.CODE_API_KEY];
|
const codeApiKey = authValues[EnvVar.CODE_API_KEY];
|
||||||
const { files, toolContext } = await primeCodeFiles(options, codeApiKey);
|
const { files, toolContext } = await primeCodeFiles(
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
agentId: agent?.id,
|
||||||
|
},
|
||||||
|
codeApiKey,
|
||||||
|
);
|
||||||
if (toolContext) {
|
if (toolContext) {
|
||||||
toolContextMap[tool] = toolContext;
|
toolContextMap[tool] = toolContext;
|
||||||
}
|
}
|
||||||
@@ -260,7 +261,10 @@ const loadTools = async ({
|
|||||||
continue;
|
continue;
|
||||||
} else if (tool === Tools.file_search) {
|
} else if (tool === Tools.file_search) {
|
||||||
requestedTools[tool] = async () => {
|
requestedTools[tool] = async () => {
|
||||||
const { files, toolContext } = await primeSearchFiles(options);
|
const { files, toolContext } = await primeSearchFiles({
|
||||||
|
...options,
|
||||||
|
agentId: agent?.id,
|
||||||
|
});
|
||||||
if (toolContext) {
|
if (toolContext) {
|
||||||
toolContextMap[tool] = toolContext;
|
toolContextMap[tool] = toolContext;
|
||||||
}
|
}
|
||||||
@@ -294,7 +298,7 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
continue;
|
continue;
|
||||||
} else if (tool && appTools[tool] && mcpToolPattern.test(tool)) {
|
} else if (tool && cachedTools && mcpToolPattern.test(tool)) {
|
||||||
requestedTools[tool] = async () =>
|
requestedTools[tool] = async () =>
|
||||||
createMCPTool({
|
createMCPTool({
|
||||||
req: options.req,
|
req: options.req,
|
||||||
|
|||||||
54
api/cache/cacheConfig.js
vendored
Normal file
54
api/cache/cacheConfig.js
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const { math, isEnabled } = require('@librechat/api');
|
||||||
|
const { CacheKeys } = require('librechat-data-provider');
|
||||||
|
|
||||||
|
// To ensure that different deployments do not interfere with each other's cache, we use a prefix for the Redis keys.
|
||||||
|
// This prefix is usually the deployment ID, which is often passed to the container or pod as an env var.
|
||||||
|
// Set REDIS_KEY_PREFIX_VAR to the env var that contains the deployment ID.
|
||||||
|
const REDIS_KEY_PREFIX_VAR = process.env.REDIS_KEY_PREFIX_VAR;
|
||||||
|
const REDIS_KEY_PREFIX = process.env.REDIS_KEY_PREFIX;
|
||||||
|
if (REDIS_KEY_PREFIX_VAR && REDIS_KEY_PREFIX) {
|
||||||
|
throw new Error('Only either REDIS_KEY_PREFIX_VAR or REDIS_KEY_PREFIX can be set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const USE_REDIS = isEnabled(process.env.USE_REDIS);
|
||||||
|
if (USE_REDIS && !process.env.REDIS_URI) {
|
||||||
|
throw new Error('USE_REDIS is enabled but REDIS_URI is not set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comma-separated list of cache namespaces that should be forced to use in-memory storage
|
||||||
|
// even when Redis is enabled. This allows selective performance optimization for specific caches.
|
||||||
|
const FORCED_IN_MEMORY_CACHE_NAMESPACES = process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES
|
||||||
|
? process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES.split(',').map((key) => key.trim())
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Validate against CacheKeys enum
|
||||||
|
if (FORCED_IN_MEMORY_CACHE_NAMESPACES.length > 0) {
|
||||||
|
const validKeys = Object.values(CacheKeys);
|
||||||
|
const invalidKeys = FORCED_IN_MEMORY_CACHE_NAMESPACES.filter((key) => !validKeys.includes(key));
|
||||||
|
|
||||||
|
if (invalidKeys.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid cache keys in FORCED_IN_MEMORY_CACHE_NAMESPACES: ${invalidKeys.join(', ')}. Valid keys: ${validKeys.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheConfig = {
|
||||||
|
FORCED_IN_MEMORY_CACHE_NAMESPACES,
|
||||||
|
USE_REDIS,
|
||||||
|
REDIS_URI: process.env.REDIS_URI,
|
||||||
|
REDIS_USERNAME: process.env.REDIS_USERNAME,
|
||||||
|
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
|
||||||
|
REDIS_CA: process.env.REDIS_CA ? fs.readFileSync(process.env.REDIS_CA, 'utf8') : null,
|
||||||
|
REDIS_KEY_PREFIX: process.env[REDIS_KEY_PREFIX_VAR] || REDIS_KEY_PREFIX || '',
|
||||||
|
REDIS_MAX_LISTENERS: math(process.env.REDIS_MAX_LISTENERS, 40),
|
||||||
|
REDIS_PING_INTERVAL: math(process.env.REDIS_PING_INTERVAL, 0),
|
||||||
|
|
||||||
|
CI: isEnabled(process.env.CI),
|
||||||
|
DEBUG_MEMORY_CACHE: isEnabled(process.env.DEBUG_MEMORY_CACHE),
|
||||||
|
|
||||||
|
BAN_DURATION: math(process.env.BAN_DURATION, 7200000), // 2 hours
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { cacheConfig };
|
||||||
157
api/cache/cacheConfig.spec.js
vendored
Normal file
157
api/cache/cacheConfig.spec.js
vendored
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
describe('cacheConfig', () => {
|
||||||
|
let originalEnv;
|
||||||
|
let originalReadFileSync;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalEnv = { ...process.env };
|
||||||
|
originalReadFileSync = fs.readFileSync;
|
||||||
|
|
||||||
|
// Clear all related env vars first
|
||||||
|
delete process.env.REDIS_URI;
|
||||||
|
delete process.env.REDIS_CA;
|
||||||
|
delete process.env.REDIS_KEY_PREFIX_VAR;
|
||||||
|
delete process.env.REDIS_KEY_PREFIX;
|
||||||
|
delete process.env.USE_REDIS;
|
||||||
|
delete process.env.REDIS_PING_INTERVAL;
|
||||||
|
delete process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES;
|
||||||
|
|
||||||
|
// Clear require cache
|
||||||
|
jest.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
fs.readFileSync = originalReadFileSync;
|
||||||
|
jest.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('REDIS_KEY_PREFIX validation and resolution', () => {
|
||||||
|
test('should throw error when both REDIS_KEY_PREFIX_VAR and REDIS_KEY_PREFIX are set', () => {
|
||||||
|
process.env.REDIS_KEY_PREFIX_VAR = 'DEPLOYMENT_ID';
|
||||||
|
process.env.REDIS_KEY_PREFIX = 'manual-prefix';
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
require('./cacheConfig');
|
||||||
|
}).toThrow('Only either REDIS_KEY_PREFIX_VAR or REDIS_KEY_PREFIX can be set.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should resolve REDIS_KEY_PREFIX from variable reference', () => {
|
||||||
|
process.env.REDIS_KEY_PREFIX_VAR = 'DEPLOYMENT_ID';
|
||||||
|
process.env.DEPLOYMENT_ID = 'test-deployment-123';
|
||||||
|
|
||||||
|
const { cacheConfig } = require('./cacheConfig');
|
||||||
|
expect(cacheConfig.REDIS_KEY_PREFIX).toBe('test-deployment-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should use direct REDIS_KEY_PREFIX value', () => {
|
||||||
|
process.env.REDIS_KEY_PREFIX = 'direct-prefix';
|
||||||
|
|
||||||
|
const { cacheConfig } = require('./cacheConfig');
|
||||||
|
expect(cacheConfig.REDIS_KEY_PREFIX).toBe('direct-prefix');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should default to empty string when no prefix is configured', () => {
|
||||||
|
const { cacheConfig } = require('./cacheConfig');
|
||||||
|
expect(cacheConfig.REDIS_KEY_PREFIX).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty variable reference', () => {
|
||||||
|
process.env.REDIS_KEY_PREFIX_VAR = 'EMPTY_VAR';
|
||||||
|
process.env.EMPTY_VAR = '';
|
||||||
|
|
||||||
|
const { cacheConfig } = require('./cacheConfig');
|
||||||
|
expect(cacheConfig.REDIS_KEY_PREFIX).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle undefined variable reference', () => {
|
||||||
|
process.env.REDIS_KEY_PREFIX_VAR = 'UNDEFINED_VAR';
|
||||||
|
|
||||||
|
const { cacheConfig } = require('./cacheConfig');
|
||||||
|
expect(cacheConfig.REDIS_KEY_PREFIX).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('USE_REDIS and REDIS_URI validation', () => {
|
||||||
|
test('should throw error when USE_REDIS is enabled but REDIS_URI is not set', () => {
|
||||||
|
process.env.USE_REDIS = 'true';
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
require('./cacheConfig');
|
||||||
|
}).toThrow('USE_REDIS is enabled but REDIS_URI is not set.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not throw error when USE_REDIS is enabled and REDIS_URI is set', () => {
|
||||||
|
process.env.USE_REDIS = 'true';
|
||||||
|
process.env.REDIS_URI = 'redis://localhost:6379';
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
require('./cacheConfig');
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty REDIS_URI when USE_REDIS is enabled', () => {
|
||||||
|
process.env.USE_REDIS = 'true';
|
||||||
|
process.env.REDIS_URI = '';
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
require('./cacheConfig');
|
||||||
|
}).toThrow('USE_REDIS is enabled but REDIS_URI is not set.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('REDIS_CA file reading', () => {
|
||||||
|
test('should be null when REDIS_CA is not set', () => {
|
||||||
|
const { cacheConfig } = require('./cacheConfig');
|
||||||
|
expect(cacheConfig.REDIS_CA).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('REDIS_PING_INTERVAL configuration', () => {
|
||||||
|
test('should default to 0 when REDIS_PING_INTERVAL is not set', () => {
|
||||||
|
const { cacheConfig } = require('./cacheConfig');
|
||||||
|
expect(cacheConfig.REDIS_PING_INTERVAL).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should use provided REDIS_PING_INTERVAL value', () => {
|
||||||
|
process.env.REDIS_PING_INTERVAL = '300';
|
||||||
|
|
||||||
|
const { cacheConfig } = require('./cacheConfig');
|
||||||
|
expect(cacheConfig.REDIS_PING_INTERVAL).toBe(300);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FORCED_IN_MEMORY_CACHE_NAMESPACES validation', () => {
|
||||||
|
test('should parse comma-separated cache keys correctly', () => {
|
||||||
|
process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = ' ROLES, STATIC_CONFIG ,MESSAGES ';
|
||||||
|
|
||||||
|
const { cacheConfig } = require('./cacheConfig');
|
||||||
|
expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual([
|
||||||
|
'ROLES',
|
||||||
|
'STATIC_CONFIG',
|
||||||
|
'MESSAGES',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw error for invalid cache keys', () => {
|
||||||
|
process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = 'INVALID_KEY,ROLES';
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
require('./cacheConfig');
|
||||||
|
}).toThrow('Invalid cache keys in FORCED_IN_MEMORY_CACHE_NAMESPACES: INVALID_KEY');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty string gracefully', () => {
|
||||||
|
process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = '';
|
||||||
|
|
||||||
|
const { cacheConfig } = require('./cacheConfig');
|
||||||
|
expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle undefined env var gracefully', () => {
|
||||||
|
const { cacheConfig } = require('./cacheConfig');
|
||||||
|
expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
69
api/cache/cacheFactory.js
vendored
Normal file
69
api/cache/cacheFactory.js
vendored
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
const KeyvRedis = require('@keyv/redis').default;
|
||||||
|
const { Keyv } = require('keyv');
|
||||||
|
const { cacheConfig } = require('./cacheConfig');
|
||||||
|
const { keyvRedisClient, ioredisClient, GLOBAL_PREFIX_SEPARATOR } = require('./redisClients');
|
||||||
|
const { Time } = require('librechat-data-provider');
|
||||||
|
const { RedisStore: ConnectRedis } = require('connect-redis');
|
||||||
|
const MemoryStore = require('memorystore')(require('express-session'));
|
||||||
|
const { violationFile } = require('./keyvFiles');
|
||||||
|
const { RedisStore } = require('rate-limit-redis');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a cache instance using Redis or a fallback store. Suitable for general caching needs.
|
||||||
|
* @param {string} namespace - The cache namespace.
|
||||||
|
* @param {number} [ttl] - Time to live for cache entries.
|
||||||
|
* @param {object} [fallbackStore] - Optional fallback store if Redis is not used.
|
||||||
|
* @returns {Keyv} Cache instance.
|
||||||
|
*/
|
||||||
|
const standardCache = (namespace, ttl = undefined, fallbackStore = undefined) => {
|
||||||
|
if (
|
||||||
|
cacheConfig.USE_REDIS &&
|
||||||
|
!cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES?.includes(namespace)
|
||||||
|
) {
|
||||||
|
const keyvRedis = new KeyvRedis(keyvRedisClient);
|
||||||
|
const cache = new Keyv(keyvRedis, { namespace, ttl });
|
||||||
|
keyvRedis.namespace = cacheConfig.REDIS_KEY_PREFIX;
|
||||||
|
keyvRedis.keyPrefixSeparator = GLOBAL_PREFIX_SEPARATOR;
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
if (fallbackStore) return new Keyv({ store: fallbackStore, namespace, ttl });
|
||||||
|
return new Keyv({ namespace, ttl });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a cache instance for storing violation data.
|
||||||
|
* Uses a file-based fallback store if Redis is not enabled.
|
||||||
|
* @param {string} namespace - The cache namespace for violations.
|
||||||
|
* @param {number} [ttl] - Time to live for cache entries.
|
||||||
|
* @returns {Keyv} Cache instance for violations.
|
||||||
|
*/
|
||||||
|
const violationCache = (namespace, ttl = undefined) => {
|
||||||
|
return standardCache(`violations:${namespace}`, ttl, violationFile);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a session cache instance using Redis or in-memory store.
|
||||||
|
* @param {string} namespace - The session namespace.
|
||||||
|
* @param {number} [ttl] - Time to live for session entries.
|
||||||
|
* @returns {MemoryStore | ConnectRedis} Session store instance.
|
||||||
|
*/
|
||||||
|
const sessionCache = (namespace, ttl = undefined) => {
|
||||||
|
namespace = namespace.endsWith(':') ? namespace : `${namespace}:`;
|
||||||
|
if (!cacheConfig.USE_REDIS) return new MemoryStore({ ttl, checkPeriod: Time.ONE_DAY });
|
||||||
|
return new ConnectRedis({ client: ioredisClient, ttl, prefix: namespace });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a rate limiter cache using Redis.
|
||||||
|
* @param {string} prefix - The key prefix for rate limiting.
|
||||||
|
* @returns {RedisStore|undefined} RedisStore instance or undefined if Redis is not used.
|
||||||
|
*/
|
||||||
|
const limiterCache = (prefix) => {
|
||||||
|
if (!prefix) throw new Error('prefix is required');
|
||||||
|
if (!cacheConfig.USE_REDIS) return undefined;
|
||||||
|
prefix = prefix.endsWith(':') ? prefix : `${prefix}:`;
|
||||||
|
return new RedisStore({ sendCommand, prefix });
|
||||||
|
};
|
||||||
|
const sendCommand = (...args) => ioredisClient?.call(...args);
|
||||||
|
|
||||||
|
module.exports = { standardCache, sessionCache, violationCache, limiterCache };
|
||||||
296
api/cache/cacheFactory.spec.js
vendored
Normal file
296
api/cache/cacheFactory.spec.js
vendored
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
const { Time } = require('librechat-data-provider');
|
||||||
|
|
||||||
|
// Mock dependencies first
|
||||||
|
const mockKeyvRedis = {
|
||||||
|
namespace: '',
|
||||||
|
keyPrefixSeparator: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockKeyv = jest.fn().mockReturnValue({ mock: 'keyv' });
|
||||||
|
const mockConnectRedis = jest.fn().mockReturnValue({ mock: 'connectRedis' });
|
||||||
|
const mockMemoryStore = jest.fn().mockReturnValue({ mock: 'memoryStore' });
|
||||||
|
const mockRedisStore = jest.fn().mockReturnValue({ mock: 'redisStore' });
|
||||||
|
|
||||||
|
const mockIoredisClient = {
|
||||||
|
call: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockKeyvRedisClient = {};
|
||||||
|
const mockViolationFile = {};
|
||||||
|
|
||||||
|
// Mock modules before requiring the main module
|
||||||
|
jest.mock('@keyv/redis', () => ({
|
||||||
|
default: jest.fn().mockImplementation(() => mockKeyvRedis),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('keyv', () => ({
|
||||||
|
Keyv: mockKeyv,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./cacheConfig', () => ({
|
||||||
|
cacheConfig: {
|
||||||
|
USE_REDIS: false,
|
||||||
|
REDIS_KEY_PREFIX: 'test',
|
||||||
|
FORCED_IN_MEMORY_CACHE_NAMESPACES: [],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./redisClients', () => ({
|
||||||
|
keyvRedisClient: mockKeyvRedisClient,
|
||||||
|
ioredisClient: mockIoredisClient,
|
||||||
|
GLOBAL_PREFIX_SEPARATOR: '::',
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./keyvFiles', () => ({
|
||||||
|
violationFile: mockViolationFile,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('connect-redis', () => ({ RedisStore: mockConnectRedis }));
|
||||||
|
|
||||||
|
jest.mock('memorystore', () => jest.fn(() => mockMemoryStore));
|
||||||
|
|
||||||
|
jest.mock('rate-limit-redis', () => ({
|
||||||
|
RedisStore: mockRedisStore,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocking
|
||||||
|
const { standardCache, sessionCache, violationCache, limiterCache } = require('./cacheFactory');
|
||||||
|
const { cacheConfig } = require('./cacheConfig');
|
||||||
|
|
||||||
|
describe('cacheFactory', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Reset cache config mock
|
||||||
|
cacheConfig.USE_REDIS = false;
|
||||||
|
cacheConfig.REDIS_KEY_PREFIX = 'test';
|
||||||
|
cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('redisCache', () => {
|
||||||
|
it('should create Redis cache when USE_REDIS is true', () => {
|
||||||
|
cacheConfig.USE_REDIS = true;
|
||||||
|
const namespace = 'test-namespace';
|
||||||
|
const ttl = 3600;
|
||||||
|
|
||||||
|
standardCache(namespace, ttl);
|
||||||
|
|
||||||
|
expect(require('@keyv/redis').default).toHaveBeenCalledWith(mockKeyvRedisClient);
|
||||||
|
expect(mockKeyv).toHaveBeenCalledWith(mockKeyvRedis, { namespace, ttl });
|
||||||
|
expect(mockKeyvRedis.namespace).toBe(cacheConfig.REDIS_KEY_PREFIX);
|
||||||
|
expect(mockKeyvRedis.keyPrefixSeparator).toBe('::');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create Redis cache with undefined ttl when not provided', () => {
|
||||||
|
cacheConfig.USE_REDIS = true;
|
||||||
|
const namespace = 'test-namespace';
|
||||||
|
|
||||||
|
standardCache(namespace);
|
||||||
|
|
||||||
|
expect(mockKeyv).toHaveBeenCalledWith(mockKeyvRedis, { namespace, ttl: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use fallback store when USE_REDIS is false and fallbackStore is provided', () => {
|
||||||
|
cacheConfig.USE_REDIS = false;
|
||||||
|
const namespace = 'test-namespace';
|
||||||
|
const ttl = 3600;
|
||||||
|
const fallbackStore = { some: 'store' };
|
||||||
|
|
||||||
|
standardCache(namespace, ttl, fallbackStore);
|
||||||
|
|
||||||
|
expect(mockKeyv).toHaveBeenCalledWith({ store: fallbackStore, namespace, ttl });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create default Keyv instance when USE_REDIS is false and no fallbackStore', () => {
|
||||||
|
cacheConfig.USE_REDIS = false;
|
||||||
|
const namespace = 'test-namespace';
|
||||||
|
const ttl = 3600;
|
||||||
|
|
||||||
|
standardCache(namespace, ttl);
|
||||||
|
|
||||||
|
expect(mockKeyv).toHaveBeenCalledWith({ namespace, ttl });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle namespace and ttl as undefined', () => {
|
||||||
|
cacheConfig.USE_REDIS = false;
|
||||||
|
|
||||||
|
standardCache();
|
||||||
|
|
||||||
|
expect(mockKeyv).toHaveBeenCalledWith({ namespace: undefined, ttl: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use fallback when namespace is in FORCED_IN_MEMORY_CACHE_NAMESPACES', () => {
|
||||||
|
cacheConfig.USE_REDIS = true;
|
||||||
|
cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES = ['forced-memory'];
|
||||||
|
const namespace = 'forced-memory';
|
||||||
|
const ttl = 3600;
|
||||||
|
|
||||||
|
standardCache(namespace, ttl);
|
||||||
|
|
||||||
|
expect(require('@keyv/redis').default).not.toHaveBeenCalled();
|
||||||
|
expect(mockKeyv).toHaveBeenCalledWith({ namespace, ttl });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use Redis when namespace is not in FORCED_IN_MEMORY_CACHE_NAMESPACES', () => {
|
||||||
|
cacheConfig.USE_REDIS = true;
|
||||||
|
cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES = ['other-namespace'];
|
||||||
|
const namespace = 'test-namespace';
|
||||||
|
const ttl = 3600;
|
||||||
|
|
||||||
|
standardCache(namespace, ttl);
|
||||||
|
|
||||||
|
expect(require('@keyv/redis').default).toHaveBeenCalledWith(mockKeyvRedisClient);
|
||||||
|
expect(mockKeyv).toHaveBeenCalledWith(mockKeyvRedis, { namespace, ttl });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('violationCache', () => {
|
||||||
|
it('should create violation cache with prefixed namespace', () => {
|
||||||
|
const namespace = 'test-violations';
|
||||||
|
const ttl = 7200;
|
||||||
|
|
||||||
|
// We can't easily mock the internal redisCache call since it's in the same module
|
||||||
|
// But we can test that the function executes without throwing
|
||||||
|
expect(() => violationCache(namespace, ttl)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create violation cache with undefined ttl', () => {
|
||||||
|
const namespace = 'test-violations';
|
||||||
|
|
||||||
|
violationCache(namespace);
|
||||||
|
|
||||||
|
// The function should call redisCache with violations: prefixed namespace
|
||||||
|
// Since we can't easily mock the internal redisCache call, we test the behavior
|
||||||
|
expect(() => violationCache(namespace)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined namespace', () => {
|
||||||
|
expect(() => violationCache(undefined)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sessionCache', () => {
|
||||||
|
it('should return MemoryStore when USE_REDIS is false', () => {
|
||||||
|
cacheConfig.USE_REDIS = false;
|
||||||
|
const namespace = 'sessions';
|
||||||
|
const ttl = 86400;
|
||||||
|
|
||||||
|
const result = sessionCache(namespace, ttl);
|
||||||
|
|
||||||
|
expect(mockMemoryStore).toHaveBeenCalledWith({ ttl, checkPeriod: Time.ONE_DAY });
|
||||||
|
expect(result).toBe(mockMemoryStore());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return ConnectRedis when USE_REDIS is true', () => {
|
||||||
|
cacheConfig.USE_REDIS = true;
|
||||||
|
const namespace = 'sessions';
|
||||||
|
const ttl = 86400;
|
||||||
|
|
||||||
|
const result = sessionCache(namespace, ttl);
|
||||||
|
|
||||||
|
expect(mockConnectRedis).toHaveBeenCalledWith({
|
||||||
|
client: mockIoredisClient,
|
||||||
|
ttl,
|
||||||
|
prefix: `${namespace}:`,
|
||||||
|
});
|
||||||
|
expect(result).toBe(mockConnectRedis());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add colon to namespace if not present', () => {
|
||||||
|
cacheConfig.USE_REDIS = true;
|
||||||
|
const namespace = 'sessions';
|
||||||
|
|
||||||
|
sessionCache(namespace);
|
||||||
|
|
||||||
|
expect(mockConnectRedis).toHaveBeenCalledWith({
|
||||||
|
client: mockIoredisClient,
|
||||||
|
ttl: undefined,
|
||||||
|
prefix: 'sessions:',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not add colon to namespace if already present', () => {
|
||||||
|
cacheConfig.USE_REDIS = true;
|
||||||
|
const namespace = 'sessions:';
|
||||||
|
|
||||||
|
sessionCache(namespace);
|
||||||
|
|
||||||
|
expect(mockConnectRedis).toHaveBeenCalledWith({
|
||||||
|
client: mockIoredisClient,
|
||||||
|
ttl: undefined,
|
||||||
|
prefix: 'sessions:',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined ttl', () => {
|
||||||
|
cacheConfig.USE_REDIS = false;
|
||||||
|
const namespace = 'sessions';
|
||||||
|
|
||||||
|
sessionCache(namespace);
|
||||||
|
|
||||||
|
expect(mockMemoryStore).toHaveBeenCalledWith({
|
||||||
|
ttl: undefined,
|
||||||
|
checkPeriod: Time.ONE_DAY,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('limiterCache', () => {
|
||||||
|
it('should return undefined when USE_REDIS is false', () => {
|
||||||
|
cacheConfig.USE_REDIS = false;
|
||||||
|
const result = limiterCache('prefix');
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return RedisStore when USE_REDIS is true', () => {
|
||||||
|
cacheConfig.USE_REDIS = true;
|
||||||
|
const result = limiterCache('rate-limit');
|
||||||
|
|
||||||
|
expect(mockRedisStore).toHaveBeenCalledWith({
|
||||||
|
sendCommand: expect.any(Function),
|
||||||
|
prefix: `rate-limit:`,
|
||||||
|
});
|
||||||
|
expect(result).toBe(mockRedisStore());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add colon to prefix if not present', () => {
|
||||||
|
cacheConfig.USE_REDIS = true;
|
||||||
|
limiterCache('rate-limit');
|
||||||
|
|
||||||
|
expect(mockRedisStore).toHaveBeenCalledWith({
|
||||||
|
sendCommand: expect.any(Function),
|
||||||
|
prefix: 'rate-limit:',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not add colon to prefix if already present', () => {
|
||||||
|
cacheConfig.USE_REDIS = true;
|
||||||
|
limiterCache('rate-limit:');
|
||||||
|
|
||||||
|
expect(mockRedisStore).toHaveBeenCalledWith({
|
||||||
|
sendCommand: expect.any(Function),
|
||||||
|
prefix: 'rate-limit:',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass sendCommand function that calls ioredisClient.call', () => {
|
||||||
|
cacheConfig.USE_REDIS = true;
|
||||||
|
limiterCache('rate-limit');
|
||||||
|
|
||||||
|
const sendCommandCall = mockRedisStore.mock.calls[0][0];
|
||||||
|
const sendCommand = sendCommandCall.sendCommand;
|
||||||
|
|
||||||
|
// Test that sendCommand properly delegates to ioredisClient.call
|
||||||
|
const args = ['GET', 'test-key'];
|
||||||
|
sendCommand(...args);
|
||||||
|
|
||||||
|
expect(mockIoredisClient.call).toHaveBeenCalledWith(...args);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined prefix', () => {
|
||||||
|
cacheConfig.USE_REDIS = true;
|
||||||
|
expect(() => limiterCache()).toThrow('prefix is required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
165
api/cache/getLogStores.js
vendored
165
api/cache/getLogStores.js
vendored
@@ -1,113 +1,53 @@
|
|||||||
|
const { cacheConfig } = require('./cacheConfig');
|
||||||
const { Keyv } = require('keyv');
|
const { Keyv } = require('keyv');
|
||||||
const { isEnabled, math } = require('@librechat/api');
|
|
||||||
const { CacheKeys, ViolationTypes, Time } = require('librechat-data-provider');
|
const { CacheKeys, ViolationTypes, Time } = require('librechat-data-provider');
|
||||||
const { logFile, violationFile } = require('./keyvFiles');
|
const { logFile } = require('./keyvFiles');
|
||||||
const keyvRedis = require('./keyvRedis');
|
|
||||||
const keyvMongo = require('./keyvMongo');
|
const keyvMongo = require('./keyvMongo');
|
||||||
|
const { standardCache, sessionCache, violationCache } = require('./cacheFactory');
|
||||||
const { BAN_DURATION, USE_REDIS, DEBUG_MEMORY_CACHE, CI } = process.env ?? {};
|
|
||||||
|
|
||||||
const duration = math(BAN_DURATION, 7200000);
|
|
||||||
const isRedisEnabled = isEnabled(USE_REDIS);
|
|
||||||
const debugMemoryCache = isEnabled(DEBUG_MEMORY_CACHE);
|
|
||||||
|
|
||||||
const createViolationInstance = (namespace) => {
|
|
||||||
const config = isRedisEnabled ? { store: keyvRedis } : { store: violationFile, namespace };
|
|
||||||
return new Keyv(config);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Serve cache from memory so no need to clear it on startup/exit
|
|
||||||
const pending_req = isRedisEnabled
|
|
||||||
? new Keyv({ store: keyvRedis })
|
|
||||||
: new Keyv({ namespace: CacheKeys.PENDING_REQ });
|
|
||||||
|
|
||||||
const config = isRedisEnabled
|
|
||||||
? new Keyv({ store: keyvRedis })
|
|
||||||
: new Keyv({ namespace: CacheKeys.CONFIG_STORE });
|
|
||||||
|
|
||||||
const roles = isRedisEnabled
|
|
||||||
? new Keyv({ store: keyvRedis })
|
|
||||||
: new Keyv({ namespace: CacheKeys.ROLES });
|
|
||||||
|
|
||||||
const mcpTools = isRedisEnabled
|
|
||||||
? new Keyv({ store: keyvRedis })
|
|
||||||
: new Keyv({ namespace: CacheKeys.MCP_TOOLS });
|
|
||||||
|
|
||||||
const audioRuns = isRedisEnabled
|
|
||||||
? new Keyv({ store: keyvRedis, ttl: Time.TEN_MINUTES })
|
|
||||||
: new Keyv({ namespace: CacheKeys.AUDIO_RUNS, ttl: Time.TEN_MINUTES });
|
|
||||||
|
|
||||||
const messages = isRedisEnabled
|
|
||||||
? new Keyv({ store: keyvRedis, ttl: Time.ONE_MINUTE })
|
|
||||||
: new Keyv({ namespace: CacheKeys.MESSAGES, ttl: Time.ONE_MINUTE });
|
|
||||||
|
|
||||||
const flows = isRedisEnabled
|
|
||||||
? new Keyv({ store: keyvRedis, ttl: Time.TWO_MINUTES })
|
|
||||||
: new Keyv({ namespace: CacheKeys.FLOWS, ttl: Time.ONE_MINUTE * 3 });
|
|
||||||
|
|
||||||
const tokenConfig = isRedisEnabled
|
|
||||||
? new Keyv({ store: keyvRedis, ttl: Time.THIRTY_MINUTES })
|
|
||||||
: new Keyv({ namespace: CacheKeys.TOKEN_CONFIG, ttl: Time.THIRTY_MINUTES });
|
|
||||||
|
|
||||||
const genTitle = isRedisEnabled
|
|
||||||
? new Keyv({ store: keyvRedis, ttl: Time.TWO_MINUTES })
|
|
||||||
: new Keyv({ namespace: CacheKeys.GEN_TITLE, ttl: Time.TWO_MINUTES });
|
|
||||||
|
|
||||||
const s3ExpiryInterval = isRedisEnabled
|
|
||||||
? new Keyv({ store: keyvRedis, ttl: Time.THIRTY_MINUTES })
|
|
||||||
: new Keyv({ namespace: CacheKeys.S3_EXPIRY_INTERVAL, ttl: Time.THIRTY_MINUTES });
|
|
||||||
|
|
||||||
const modelQueries = isEnabled(process.env.USE_REDIS)
|
|
||||||
? new Keyv({ store: keyvRedis })
|
|
||||||
: new Keyv({ namespace: CacheKeys.MODEL_QUERIES });
|
|
||||||
|
|
||||||
const abortKeys = isRedisEnabled
|
|
||||||
? new Keyv({ store: keyvRedis })
|
|
||||||
: 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,
|
[ViolationTypes.GENERAL]: new Keyv({ store: logFile, namespace: 'violations' }),
|
||||||
[CacheKeys.MCP_TOOLS]: mcpTools,
|
[ViolationTypes.LOGINS]: violationCache(ViolationTypes.LOGINS),
|
||||||
[CacheKeys.CONFIG_STORE]: config,
|
[ViolationTypes.CONCURRENT]: violationCache(ViolationTypes.CONCURRENT),
|
||||||
[CacheKeys.PENDING_REQ]: pending_req,
|
[ViolationTypes.NON_BROWSER]: violationCache(ViolationTypes.NON_BROWSER),
|
||||||
[ViolationTypes.BAN]: new Keyv({ store: keyvMongo, namespace: CacheKeys.BANS, ttl: duration }),
|
[ViolationTypes.MESSAGE_LIMIT]: violationCache(ViolationTypes.MESSAGE_LIMIT),
|
||||||
[CacheKeys.ENCODED_DOMAINS]: new Keyv({
|
[ViolationTypes.REGISTRATIONS]: violationCache(ViolationTypes.REGISTRATIONS),
|
||||||
|
[ViolationTypes.TOKEN_BALANCE]: violationCache(ViolationTypes.TOKEN_BALANCE),
|
||||||
|
[ViolationTypes.TTS_LIMIT]: violationCache(ViolationTypes.TTS_LIMIT),
|
||||||
|
[ViolationTypes.STT_LIMIT]: violationCache(ViolationTypes.STT_LIMIT),
|
||||||
|
[ViolationTypes.CONVO_ACCESS]: violationCache(ViolationTypes.CONVO_ACCESS),
|
||||||
|
[ViolationTypes.TOOL_CALL_LIMIT]: violationCache(ViolationTypes.TOOL_CALL_LIMIT),
|
||||||
|
[ViolationTypes.FILE_UPLOAD_LIMIT]: violationCache(ViolationTypes.FILE_UPLOAD_LIMIT),
|
||||||
|
[ViolationTypes.VERIFY_EMAIL_LIMIT]: violationCache(ViolationTypes.VERIFY_EMAIL_LIMIT),
|
||||||
|
[ViolationTypes.RESET_PASSWORD_LIMIT]: violationCache(ViolationTypes.RESET_PASSWORD_LIMIT),
|
||||||
|
[ViolationTypes.ILLEGAL_MODEL_REQUEST]: violationCache(ViolationTypes.ILLEGAL_MODEL_REQUEST),
|
||||||
|
[ViolationTypes.BAN]: new Keyv({
|
||||||
store: keyvMongo,
|
store: keyvMongo,
|
||||||
namespace: CacheKeys.ENCODED_DOMAINS,
|
namespace: CacheKeys.BANS,
|
||||||
ttl: 0,
|
ttl: cacheConfig.BAN_DURATION,
|
||||||
}),
|
}),
|
||||||
general: new Keyv({ store: logFile, namespace: 'violations' }),
|
|
||||||
concurrent: createViolationInstance('concurrent'),
|
[CacheKeys.OPENID_SESSION]: sessionCache(CacheKeys.OPENID_SESSION),
|
||||||
non_browser: createViolationInstance('non_browser'),
|
[CacheKeys.SAML_SESSION]: sessionCache(CacheKeys.SAML_SESSION),
|
||||||
message_limit: createViolationInstance('message_limit'),
|
|
||||||
token_balance: createViolationInstance(ViolationTypes.TOKEN_BALANCE),
|
[CacheKeys.ROLES]: standardCache(CacheKeys.ROLES),
|
||||||
registrations: createViolationInstance('registrations'),
|
[CacheKeys.MCP_TOOLS]: standardCache(CacheKeys.MCP_TOOLS),
|
||||||
[ViolationTypes.TTS_LIMIT]: createViolationInstance(ViolationTypes.TTS_LIMIT),
|
[CacheKeys.CONFIG_STORE]: standardCache(CacheKeys.CONFIG_STORE),
|
||||||
[ViolationTypes.STT_LIMIT]: createViolationInstance(ViolationTypes.STT_LIMIT),
|
[CacheKeys.STATIC_CONFIG]: standardCache(CacheKeys.STATIC_CONFIG),
|
||||||
[ViolationTypes.CONVO_ACCESS]: createViolationInstance(ViolationTypes.CONVO_ACCESS),
|
[CacheKeys.PENDING_REQ]: standardCache(CacheKeys.PENDING_REQ),
|
||||||
[ViolationTypes.TOOL_CALL_LIMIT]: createViolationInstance(ViolationTypes.TOOL_CALL_LIMIT),
|
[CacheKeys.ENCODED_DOMAINS]: new Keyv({ store: keyvMongo, namespace: CacheKeys.ENCODED_DOMAINS }),
|
||||||
[ViolationTypes.FILE_UPLOAD_LIMIT]: createViolationInstance(ViolationTypes.FILE_UPLOAD_LIMIT),
|
[CacheKeys.ABORT_KEYS]: standardCache(CacheKeys.ABORT_KEYS, Time.TEN_MINUTES),
|
||||||
[ViolationTypes.VERIFY_EMAIL_LIMIT]: createViolationInstance(ViolationTypes.VERIFY_EMAIL_LIMIT),
|
[CacheKeys.TOKEN_CONFIG]: standardCache(CacheKeys.TOKEN_CONFIG, Time.THIRTY_MINUTES),
|
||||||
[ViolationTypes.RESET_PASSWORD_LIMIT]: createViolationInstance(
|
[CacheKeys.GEN_TITLE]: standardCache(CacheKeys.GEN_TITLE, Time.TWO_MINUTES),
|
||||||
ViolationTypes.RESET_PASSWORD_LIMIT,
|
[CacheKeys.S3_EXPIRY_INTERVAL]: standardCache(CacheKeys.S3_EXPIRY_INTERVAL, Time.THIRTY_MINUTES),
|
||||||
|
[CacheKeys.MODEL_QUERIES]: standardCache(CacheKeys.MODEL_QUERIES),
|
||||||
|
[CacheKeys.AUDIO_RUNS]: standardCache(CacheKeys.AUDIO_RUNS, Time.TEN_MINUTES),
|
||||||
|
[CacheKeys.MESSAGES]: standardCache(CacheKeys.MESSAGES, Time.ONE_MINUTE),
|
||||||
|
[CacheKeys.FLOWS]: standardCache(CacheKeys.FLOWS, Time.ONE_MINUTE * 3),
|
||||||
|
[CacheKeys.OPENID_EXCHANGED_TOKENS]: standardCache(
|
||||||
|
CacheKeys.OPENID_EXCHANGED_TOKENS,
|
||||||
|
Time.TEN_MINUTES,
|
||||||
),
|
),
|
||||||
[ViolationTypes.ILLEGAL_MODEL_REQUEST]: createViolationInstance(
|
|
||||||
ViolationTypes.ILLEGAL_MODEL_REQUEST,
|
|
||||||
),
|
|
||||||
logins: createViolationInstance('logins'),
|
|
||||||
[CacheKeys.ABORT_KEYS]: abortKeys,
|
|
||||||
[CacheKeys.TOKEN_CONFIG]: tokenConfig,
|
|
||||||
[CacheKeys.GEN_TITLE]: genTitle,
|
|
||||||
[CacheKeys.S3_EXPIRY_INTERVAL]: s3ExpiryInterval,
|
|
||||||
[CacheKeys.MODEL_QUERIES]: modelQueries,
|
|
||||||
[CacheKeys.AUDIO_RUNS]: audioRuns,
|
|
||||||
[CacheKeys.MESSAGES]: messages,
|
|
||||||
[CacheKeys.FLOWS]: flows,
|
|
||||||
[CacheKeys.OPENID_EXCHANGED_TOKENS]: openIdExchangedTokensCache,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,7 +56,10 @@ const namespaces = {
|
|||||||
*/
|
*/
|
||||||
function getTTLStores() {
|
function getTTLStores() {
|
||||||
return Object.values(namespaces).filter(
|
return Object.values(namespaces).filter(
|
||||||
(store) => store instanceof Keyv && typeof store.opts?.ttl === 'number' && store.opts.ttl > 0,
|
(store) =>
|
||||||
|
store instanceof Keyv &&
|
||||||
|
parseInt(store.opts?.ttl ?? '0') > 0 &&
|
||||||
|
!store.opts?.store?.constructor?.name?.includes('Redis'), // Only include non-Redis stores
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,18 +95,18 @@ async function clearExpiredFromCache(cache) {
|
|||||||
if (data?.expires && data.expires <= expiryTime) {
|
if (data?.expires && data.expires <= expiryTime) {
|
||||||
const deleted = await cache.opts.store.delete(key);
|
const deleted = await cache.opts.store.delete(key);
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
debugMemoryCache &&
|
cacheConfig.DEBUG_MEMORY_CACHE &&
|
||||||
console.warn(`[Cache] Error deleting entry: ${key} from ${cache.opts.namespace}`);
|
console.warn(`[Cache] Error deleting entry: ${key} from ${cache.opts.namespace}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
cleared++;
|
cleared++;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugMemoryCache &&
|
cacheConfig.DEBUG_MEMORY_CACHE &&
|
||||||
console.log(`[Cache] Error processing entry from ${cache.opts.namespace}:`, error);
|
console.log(`[Cache] Error processing entry from ${cache.opts.namespace}:`, error);
|
||||||
const deleted = await cache.opts.store.delete(key);
|
const deleted = await cache.opts.store.delete(key);
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
debugMemoryCache &&
|
cacheConfig.DEBUG_MEMORY_CACHE &&
|
||||||
console.warn(`[Cache] Error deleting entry: ${key} from ${cache.opts.namespace}`);
|
console.warn(`[Cache] Error deleting entry: ${key} from ${cache.opts.namespace}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -172,7 +115,7 @@ async function clearExpiredFromCache(cache) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (cleared > 0) {
|
if (cleared > 0) {
|
||||||
debugMemoryCache &&
|
cacheConfig.DEBUG_MEMORY_CACHE &&
|
||||||
console.log(
|
console.log(
|
||||||
`[Cache] Cleared ${cleared} entries older than ${ttl}ms from ${cache.opts.namespace}`,
|
`[Cache] Cleared ${cleared} entries older than ${ttl}ms from ${cache.opts.namespace}`,
|
||||||
);
|
);
|
||||||
@@ -213,7 +156,7 @@ async function clearAllExpiredFromCache() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isRedisEnabled && !isEnabled(CI)) {
|
if (!cacheConfig.USE_REDIS && !cacheConfig.CI) {
|
||||||
/** @type {Set<NodeJS.Timeout>} */
|
/** @type {Set<NodeJS.Timeout>} */
|
||||||
const cleanupIntervals = new Set();
|
const cleanupIntervals = new Set();
|
||||||
|
|
||||||
@@ -224,7 +167,7 @@ if (!isRedisEnabled && !isEnabled(CI)) {
|
|||||||
|
|
||||||
cleanupIntervals.add(cleanup);
|
cleanupIntervals.add(cleanup);
|
||||||
|
|
||||||
if (debugMemoryCache) {
|
if (cacheConfig.DEBUG_MEMORY_CACHE) {
|
||||||
const monitor = setInterval(() => {
|
const monitor = setInterval(() => {
|
||||||
const ttlStores = getTTLStores();
|
const ttlStores = getTTLStores();
|
||||||
const memory = process.memoryUsage();
|
const memory = process.memoryUsage();
|
||||||
@@ -245,13 +188,13 @@ if (!isRedisEnabled && !isEnabled(CI)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dispose = () => {
|
const dispose = () => {
|
||||||
debugMemoryCache && console.log('[Cache] Cleaning up and shutting down...');
|
cacheConfig.DEBUG_MEMORY_CACHE && console.log('[Cache] Cleaning up and shutting down...');
|
||||||
cleanupIntervals.forEach((interval) => clearInterval(interval));
|
cleanupIntervals.forEach((interval) => clearInterval(interval));
|
||||||
cleanupIntervals.clear();
|
cleanupIntervals.clear();
|
||||||
|
|
||||||
// One final cleanup before exit
|
// One final cleanup before exit
|
||||||
clearAllExpiredFromCache().then(() => {
|
clearAllExpiredFromCache().then(() => {
|
||||||
debugMemoryCache && console.log('[Cache] Final cleanup completed');
|
cacheConfig.DEBUG_MEMORY_CACHE && console.log('[Cache] Final cleanup completed');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
92
api/cache/ioredisClient.js
vendored
92
api/cache/ioredisClient.js
vendored
@@ -1,92 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const Redis = require('ioredis');
|
|
||||||
const { isEnabled } = require('~/server/utils');
|
|
||||||
const logger = require('~/config/winston');
|
|
||||||
|
|
||||||
const { REDIS_URI, USE_REDIS, USE_REDIS_CLUSTER, REDIS_CA, REDIS_MAX_LISTENERS } = process.env;
|
|
||||||
|
|
||||||
/** @type {import('ioredis').Redis | import('ioredis').Cluster} */
|
|
||||||
let ioredisClient;
|
|
||||||
const redis_max_listeners = Number(REDIS_MAX_LISTENERS) || 40;
|
|
||||||
|
|
||||||
function mapURI(uri) {
|
|
||||||
const regex =
|
|
||||||
/^(?:(?<scheme>\w+):\/\/)?(?:(?<user>[^:@]+)(?::(?<password>[^@]+))?@)?(?<host>[\w.-]+)(?::(?<port>\d{1,5}))?$/;
|
|
||||||
const match = uri.match(regex);
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
const { scheme, user, password, host, port } = match.groups;
|
|
||||||
|
|
||||||
return {
|
|
||||||
scheme: scheme || 'none',
|
|
||||||
user: user || null,
|
|
||||||
password: password || null,
|
|
||||||
host: host || null,
|
|
||||||
port: port || null,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const parts = uri.split(':');
|
|
||||||
if (parts.length === 2) {
|
|
||||||
return {
|
|
||||||
scheme: 'none',
|
|
||||||
user: null,
|
|
||||||
password: null,
|
|
||||||
host: parts[0],
|
|
||||||
port: parts[1],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
scheme: 'none',
|
|
||||||
user: null,
|
|
||||||
password: null,
|
|
||||||
host: uri,
|
|
||||||
port: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (REDIS_URI && isEnabled(USE_REDIS)) {
|
|
||||||
let redisOptions = null;
|
|
||||||
|
|
||||||
if (REDIS_CA) {
|
|
||||||
const ca = fs.readFileSync(REDIS_CA);
|
|
||||||
redisOptions = { tls: { ca } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEnabled(USE_REDIS_CLUSTER)) {
|
|
||||||
const hosts = REDIS_URI.split(',').map((item) => {
|
|
||||||
var value = mapURI(item);
|
|
||||||
|
|
||||||
return {
|
|
||||||
host: value.host,
|
|
||||||
port: value.port,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
ioredisClient = new Redis.Cluster(hosts, { redisOptions });
|
|
||||||
} else {
|
|
||||||
ioredisClient = new Redis(REDIS_URI, redisOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
ioredisClient.on('ready', () => {
|
|
||||||
logger.info('IoRedis connection ready');
|
|
||||||
});
|
|
||||||
ioredisClient.on('reconnecting', () => {
|
|
||||||
logger.info('IoRedis connection reconnecting');
|
|
||||||
});
|
|
||||||
ioredisClient.on('end', () => {
|
|
||||||
logger.info('IoRedis connection ended');
|
|
||||||
});
|
|
||||||
ioredisClient.on('close', () => {
|
|
||||||
logger.info('IoRedis connection closed');
|
|
||||||
});
|
|
||||||
ioredisClient.on('error', (err) => logger.error('IoRedis connection error:', err));
|
|
||||||
ioredisClient.setMaxListeners(redis_max_listeners);
|
|
||||||
logger.info(
|
|
||||||
'[Optional] IoRedis initialized for rate limiters. If you have issues, disable Redis or restart the server.',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logger.info('[Optional] IoRedis not initialized for rate limiters.');
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = ioredisClient;
|
|
||||||
109
api/cache/keyvRedis.js
vendored
109
api/cache/keyvRedis.js
vendored
@@ -1,109 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const ioredis = require('ioredis');
|
|
||||||
const KeyvRedis = require('@keyv/redis').default;
|
|
||||||
const { isEnabled } = require('~/server/utils');
|
|
||||||
const logger = require('~/config/winston');
|
|
||||||
|
|
||||||
const { REDIS_URI, USE_REDIS, USE_REDIS_CLUSTER, REDIS_CA, REDIS_KEY_PREFIX, REDIS_MAX_LISTENERS } =
|
|
||||||
process.env;
|
|
||||||
|
|
||||||
let keyvRedis;
|
|
||||||
const redis_prefix = REDIS_KEY_PREFIX || '';
|
|
||||||
const redis_max_listeners = Number(REDIS_MAX_LISTENERS) || 40;
|
|
||||||
|
|
||||||
function mapURI(uri) {
|
|
||||||
const regex =
|
|
||||||
/^(?:(?<scheme>\w+):\/\/)?(?:(?<user>[^:@]+)(?::(?<password>[^@]+))?@)?(?<host>[\w.-]+)(?::(?<port>\d{1,5}))?$/;
|
|
||||||
const match = uri.match(regex);
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
const { scheme, user, password, host, port } = match.groups;
|
|
||||||
|
|
||||||
return {
|
|
||||||
scheme: scheme || 'none',
|
|
||||||
user: user || null,
|
|
||||||
password: password || null,
|
|
||||||
host: host || null,
|
|
||||||
port: port || null,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const parts = uri.split(':');
|
|
||||||
if (parts.length === 2) {
|
|
||||||
return {
|
|
||||||
scheme: 'none',
|
|
||||||
user: null,
|
|
||||||
password: null,
|
|
||||||
host: parts[0],
|
|
||||||
port: parts[1],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
scheme: 'none',
|
|
||||||
user: null,
|
|
||||||
password: null,
|
|
||||||
host: uri,
|
|
||||||
port: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (REDIS_URI && isEnabled(USE_REDIS)) {
|
|
||||||
let redisOptions = null;
|
|
||||||
/** @type {import('@keyv/redis').KeyvRedisOptions} */
|
|
||||||
let keyvOpts = {
|
|
||||||
useRedisSets: false,
|
|
||||||
keyPrefix: redis_prefix,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (REDIS_CA) {
|
|
||||||
const ca = fs.readFileSync(REDIS_CA);
|
|
||||||
redisOptions = { tls: { ca } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEnabled(USE_REDIS_CLUSTER)) {
|
|
||||||
const hosts = REDIS_URI.split(',').map((item) => {
|
|
||||||
var value = mapURI(item);
|
|
||||||
|
|
||||||
return {
|
|
||||||
host: value.host,
|
|
||||||
port: value.port,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const cluster = new ioredis.Cluster(hosts, { redisOptions });
|
|
||||||
keyvRedis = new KeyvRedis(cluster, keyvOpts);
|
|
||||||
} else {
|
|
||||||
keyvRedis = new KeyvRedis(REDIS_URI, keyvOpts);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pingInterval = setInterval(
|
|
||||||
() => {
|
|
||||||
logger.debug('KeyvRedis ping');
|
|
||||||
keyvRedis.client.ping().catch((err) => logger.error('Redis keep-alive ping failed:', err));
|
|
||||||
},
|
|
||||||
5 * 60 * 1000,
|
|
||||||
);
|
|
||||||
|
|
||||||
keyvRedis.on('ready', () => {
|
|
||||||
logger.info('KeyvRedis connection ready');
|
|
||||||
});
|
|
||||||
keyvRedis.on('reconnecting', () => {
|
|
||||||
logger.info('KeyvRedis connection reconnecting');
|
|
||||||
});
|
|
||||||
keyvRedis.on('end', () => {
|
|
||||||
logger.info('KeyvRedis connection ended');
|
|
||||||
});
|
|
||||||
keyvRedis.on('close', () => {
|
|
||||||
clearInterval(pingInterval);
|
|
||||||
logger.info('KeyvRedis connection closed');
|
|
||||||
});
|
|
||||||
keyvRedis.on('error', (err) => logger.error('KeyvRedis connection error:', err));
|
|
||||||
keyvRedis.setMaxListeners(redis_max_listeners);
|
|
||||||
logger.info(
|
|
||||||
'[Optional] Redis initialized. If you have issues, or seeing older values, disable it or flush cache to refresh values.',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logger.info('[Optional] Redis not initialized.');
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = keyvRedis;
|
|
||||||
5
api/cache/logViolation.js
vendored
5
api/cache/logViolation.js
vendored
@@ -1,4 +1,5 @@
|
|||||||
const { isEnabled } = require('~/server/utils');
|
const { isEnabled } = require('~/server/utils');
|
||||||
|
const { ViolationTypes } = require('librechat-data-provider');
|
||||||
const getLogStores = require('./getLogStores');
|
const getLogStores = require('./getLogStores');
|
||||||
const banViolation = require('./banViolation');
|
const banViolation = require('./banViolation');
|
||||||
|
|
||||||
@@ -9,14 +10,14 @@ const banViolation = require('./banViolation');
|
|||||||
* @param {Object} res - Express response object.
|
* @param {Object} res - Express response object.
|
||||||
* @param {string} type - The type of violation.
|
* @param {string} type - The type of violation.
|
||||||
* @param {Object} errorMessage - The error message to log.
|
* @param {Object} errorMessage - The error message to log.
|
||||||
* @param {number} [score=1] - The severity of the violation. Defaults to 1
|
* @param {number | string} [score=1] - The severity of the violation. Defaults to 1
|
||||||
*/
|
*/
|
||||||
const logViolation = async (req, res, type, errorMessage, score = 1) => {
|
const logViolation = async (req, res, type, errorMessage, score = 1) => {
|
||||||
const userId = req.user?.id ?? req.user?._id;
|
const userId = req.user?.id ?? req.user?._id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const logs = getLogStores('general');
|
const logs = getLogStores(ViolationTypes.GENERAL);
|
||||||
const violationLogs = getLogStores(type);
|
const violationLogs = getLogStores(type);
|
||||||
const key = isEnabled(process.env.USE_REDIS) ? `${type}:${userId}` : userId;
|
const key = isEnabled(process.env.USE_REDIS) ? `${type}:${userId}` : userId;
|
||||||
|
|
||||||
|
|||||||
96
api/cache/redisClients.js
vendored
Normal file
96
api/cache/redisClients.js
vendored
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
const IoRedis = require('ioredis');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const { createClient, createCluster } = require('@keyv/redis');
|
||||||
|
const { cacheConfig } = require('./cacheConfig');
|
||||||
|
|
||||||
|
const GLOBAL_PREFIX_SEPARATOR = '::';
|
||||||
|
|
||||||
|
const urls = cacheConfig.REDIS_URI?.split(',').map((uri) => new URL(uri));
|
||||||
|
const username = urls?.[0].username || cacheConfig.REDIS_USERNAME;
|
||||||
|
const password = urls?.[0].password || cacheConfig.REDIS_PASSWORD;
|
||||||
|
const ca = cacheConfig.REDIS_CA;
|
||||||
|
|
||||||
|
/** @type {import('ioredis').Redis | import('ioredis').Cluster | null} */
|
||||||
|
let ioredisClient = null;
|
||||||
|
if (cacheConfig.USE_REDIS) {
|
||||||
|
const redisOptions = {
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
tls: ca ? { ca } : undefined,
|
||||||
|
keyPrefix: `${cacheConfig.REDIS_KEY_PREFIX}${GLOBAL_PREFIX_SEPARATOR}`,
|
||||||
|
maxListeners: cacheConfig.REDIS_MAX_LISTENERS,
|
||||||
|
};
|
||||||
|
|
||||||
|
ioredisClient =
|
||||||
|
urls.length === 1
|
||||||
|
? new IoRedis(cacheConfig.REDIS_URI, redisOptions)
|
||||||
|
: new IoRedis.Cluster(cacheConfig.REDIS_URI, { redisOptions });
|
||||||
|
|
||||||
|
ioredisClient.on('error', (err) => {
|
||||||
|
logger.error('ioredis client error:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Ping Interval to keep the Redis server connection alive (if enabled) */
|
||||||
|
let pingInterval = null;
|
||||||
|
const clearPingInterval = () => {
|
||||||
|
if (pingInterval) {
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
pingInterval = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cacheConfig.REDIS_PING_INTERVAL > 0) {
|
||||||
|
pingInterval = setInterval(() => {
|
||||||
|
if (ioredisClient && ioredisClient.status === 'ready') {
|
||||||
|
ioredisClient.ping();
|
||||||
|
}
|
||||||
|
}, cacheConfig.REDIS_PING_INTERVAL * 1000);
|
||||||
|
ioredisClient.on('close', clearPingInterval);
|
||||||
|
ioredisClient.on('end', clearPingInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {import('@keyv/redis').RedisClient | import('@keyv/redis').RedisCluster | null} */
|
||||||
|
let keyvRedisClient = null;
|
||||||
|
if (cacheConfig.USE_REDIS) {
|
||||||
|
/**
|
||||||
|
* ** WARNING ** Keyv Redis client does not support Prefix like ioredis above.
|
||||||
|
* The prefix feature will be handled by the Keyv-Redis store in cacheFactory.js
|
||||||
|
*/
|
||||||
|
const redisOptions = { username, password, socket: { tls: ca != null, ca } };
|
||||||
|
|
||||||
|
keyvRedisClient =
|
||||||
|
urls.length === 1
|
||||||
|
? createClient({ url: cacheConfig.REDIS_URI, ...redisOptions })
|
||||||
|
: createCluster({
|
||||||
|
rootNodes: cacheConfig.REDIS_URI.split(',').map((url) => ({ url })),
|
||||||
|
defaults: redisOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
keyvRedisClient.setMaxListeners(cacheConfig.REDIS_MAX_LISTENERS);
|
||||||
|
|
||||||
|
keyvRedisClient.on('error', (err) => {
|
||||||
|
logger.error('@keyv/redis client error:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Ping Interval to keep the Redis server connection alive (if enabled) */
|
||||||
|
let pingInterval = null;
|
||||||
|
const clearPingInterval = () => {
|
||||||
|
if (pingInterval) {
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
pingInterval = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cacheConfig.REDIS_PING_INTERVAL > 0) {
|
||||||
|
pingInterval = setInterval(() => {
|
||||||
|
if (keyvRedisClient && keyvRedisClient.isReady) {
|
||||||
|
keyvRedisClient.ping();
|
||||||
|
}
|
||||||
|
}, cacheConfig.REDIS_PING_INTERVAL * 1000);
|
||||||
|
keyvRedisClient.on('disconnect', clearPingInterval);
|
||||||
|
keyvRedisClient.on('end', clearPingInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ioredisClient, keyvRedisClient, GLOBAL_PREFIX_SEPARATOR };
|
||||||
@@ -61,7 +61,7 @@ const getAgent = async (searchParameter) => await Agent.findOne(searchParameter)
|
|||||||
const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _m }) => {
|
const loadEphemeralAgent = async ({ 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 = await getCachedTools({ includeGlobal: true });
|
const availableTools = await getCachedTools({ userId: req.user.id, includeGlobal: true });
|
||||||
/** @type {TEphemeralAgent | null} */
|
/** @type {TEphemeralAgent | null} */
|
||||||
const ephemeralAgent = req.body.ephemeralAgent;
|
const ephemeralAgent = req.body.ephemeralAgent;
|
||||||
const mcpServers = new Set(ephemeralAgent?.mcp);
|
const mcpServers = new Set(ephemeralAgent?.mcp);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { createTempChatExpirationDate } = require('@librechat/api');
|
const { createTempChatExpirationDate } = require('@librechat/api');
|
||||||
const getCustomConfig = require('~/server/services/Config/loadCustomConfig');
|
const getCustomConfig = require('~/server/services/Config/getCustomConfig');
|
||||||
const { getMessages, deleteMessages } = require('./Message');
|
const { getMessages, deleteMessages } = require('./Message');
|
||||||
const { Conversation } = require('~/db/models');
|
const { Conversation } = require('~/db/models');
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { EToolResources, FileContext } = require('librechat-data-provider');
|
const { EToolResources, FileContext, Constants } = require('librechat-data-provider');
|
||||||
|
const { getProjectByName } = require('./Project');
|
||||||
|
const { getAgent } = require('./Agent');
|
||||||
const { File } = require('~/db/models');
|
const { File } = require('~/db/models');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,17 +14,124 @@ const findFileById = async (file_id, options = {}) => {
|
|||||||
return await File.findOne({ file_id, ...options }).lean();
|
return await File.findOne({ file_id, ...options }).lean();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a user has access to multiple files through a shared agent (batch operation)
|
||||||
|
* @param {string} userId - The user ID to check access for
|
||||||
|
* @param {string[]} fileIds - Array of file IDs to check
|
||||||
|
* @param {string} agentId - The agent ID that might grant access
|
||||||
|
* @returns {Promise<Map<string, boolean>>} Map of fileId to access status
|
||||||
|
*/
|
||||||
|
const hasAccessToFilesViaAgent = async (userId, fileIds, agentId, checkCollaborative = true) => {
|
||||||
|
const accessMap = new Map();
|
||||||
|
|
||||||
|
// Initialize all files as no access
|
||||||
|
fileIds.forEach((fileId) => accessMap.set(fileId, false));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const agent = await getAgent({ id: agentId });
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
return accessMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is the author - if so, grant access to all files
|
||||||
|
if (agent.author.toString() === userId) {
|
||||||
|
fileIds.forEach((fileId) => accessMap.set(fileId, true));
|
||||||
|
return accessMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if agent is shared with the user via projects
|
||||||
|
if (!agent.projectIds || agent.projectIds.length === 0) {
|
||||||
|
return accessMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if agent is in global project
|
||||||
|
const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, '_id');
|
||||||
|
if (
|
||||||
|
!globalProject ||
|
||||||
|
!agent.projectIds.some((pid) => pid.toString() === globalProject._id.toString())
|
||||||
|
) {
|
||||||
|
return accessMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent is globally shared - check if it's collaborative
|
||||||
|
if (checkCollaborative && !agent.isCollaborative) {
|
||||||
|
return accessMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check which files are actually attached
|
||||||
|
const attachedFileIds = new Set();
|
||||||
|
if (agent.tool_resources) {
|
||||||
|
for (const [_resourceType, resource] of Object.entries(agent.tool_resources)) {
|
||||||
|
if (resource?.file_ids && Array.isArray(resource.file_ids)) {
|
||||||
|
resource.file_ids.forEach((fileId) => attachedFileIds.add(fileId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant access only to files that are attached to this agent
|
||||||
|
fileIds.forEach((fileId) => {
|
||||||
|
if (attachedFileIds.has(fileId)) {
|
||||||
|
accessMap.set(fileId, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return accessMap;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[hasAccessToFilesViaAgent] Error checking file access:', error);
|
||||||
|
return accessMap;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves files matching a given filter, sorted by the most recently updated.
|
* Retrieves files matching a given filter, sorted by the most recently updated.
|
||||||
* @param {Object} filter - The filter criteria to apply.
|
* @param {Object} filter - The filter criteria to apply.
|
||||||
* @param {Object} [_sortOptions] - Optional sort parameters.
|
* @param {Object} [_sortOptions] - Optional sort parameters.
|
||||||
* @param {Object|String} [selectFields={ text: 0 }] - Fields to include/exclude in the query results.
|
* @param {Object|String} [selectFields={ text: 0 }] - Fields to include/exclude in the query results.
|
||||||
* Default excludes the 'text' field.
|
* Default excludes the 'text' field.
|
||||||
|
* @param {Object} [options] - Additional options
|
||||||
|
* @param {string} [options.userId] - User ID for access control
|
||||||
|
* @param {string} [options.agentId] - Agent ID that might grant access to files
|
||||||
* @returns {Promise<Array<MongoFile>>} A promise that resolves to an array of file documents.
|
* @returns {Promise<Array<MongoFile>>} A promise that resolves to an array of file documents.
|
||||||
*/
|
*/
|
||||||
const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }) => {
|
const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }, options = {}) => {
|
||||||
const sortOptions = { updatedAt: -1, ..._sortOptions };
|
const sortOptions = { updatedAt: -1, ..._sortOptions };
|
||||||
return await File.find(filter).select(selectFields).sort(sortOptions).lean();
|
const files = await File.find(filter).select(selectFields).sort(sortOptions).lean();
|
||||||
|
|
||||||
|
// If userId and agentId are provided, filter files based on access
|
||||||
|
if (options.userId && options.agentId) {
|
||||||
|
// Collect file IDs that need access check
|
||||||
|
const filesToCheck = [];
|
||||||
|
const ownedFiles = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.user && file.user.toString() === options.userId) {
|
||||||
|
ownedFiles.push(file);
|
||||||
|
} else {
|
||||||
|
filesToCheck.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filesToCheck.length === 0) {
|
||||||
|
return ownedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch check access for all non-owned files
|
||||||
|
const fileIds = filesToCheck.map((f) => f.file_id);
|
||||||
|
const accessMap = await hasAccessToFilesViaAgent(
|
||||||
|
options.userId,
|
||||||
|
fileIds,
|
||||||
|
options.agentId,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter files based on access
|
||||||
|
const accessibleFiles = filesToCheck.filter((file) => accessMap.get(file.file_id));
|
||||||
|
|
||||||
|
return [...ownedFiles, ...accessibleFiles];
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,4 +285,5 @@ module.exports = {
|
|||||||
deleteFiles,
|
deleteFiles,
|
||||||
deleteFileByFilter,
|
deleteFileByFilter,
|
||||||
batchUpdateFiles,
|
batchUpdateFiles,
|
||||||
|
hasAccessToFilesViaAgent,
|
||||||
};
|
};
|
||||||
|
|||||||
264
api/models/File.spec.js
Normal file
264
api/models/File.spec.js
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const { fileSchema } = require('@librechat/data-schemas');
|
||||||
|
const { agentSchema } = require('@librechat/data-schemas');
|
||||||
|
const { projectSchema } = require('@librechat/data-schemas');
|
||||||
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
|
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
|
||||||
|
const { getFiles, createFile } = require('./File');
|
||||||
|
const { getProjectByName } = require('./Project');
|
||||||
|
const { createAgent } = require('./Agent');
|
||||||
|
|
||||||
|
let File;
|
||||||
|
let Agent;
|
||||||
|
let Project;
|
||||||
|
|
||||||
|
describe('File Access Control', () => {
|
||||||
|
let mongoServer;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mongoServer = await MongoMemoryServer.create();
|
||||||
|
const mongoUri = mongoServer.getUri();
|
||||||
|
File = mongoose.models.File || mongoose.model('File', fileSchema);
|
||||||
|
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
|
||||||
|
Project = mongoose.models.Project || mongoose.model('Project', projectSchema);
|
||||||
|
await mongoose.connect(mongoUri);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await mongoServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await File.deleteMany({});
|
||||||
|
await Agent.deleteMany({});
|
||||||
|
await Project.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasAccessToFilesViaAgent', () => {
|
||||||
|
it('should efficiently check access for multiple files at once', async () => {
|
||||||
|
const userId = new mongoose.Types.ObjectId().toString();
|
||||||
|
const authorId = new mongoose.Types.ObjectId().toString();
|
||||||
|
const agentId = uuidv4();
|
||||||
|
const fileIds = [uuidv4(), uuidv4(), uuidv4(), uuidv4()];
|
||||||
|
|
||||||
|
// Create files
|
||||||
|
for (const fileId of fileIds) {
|
||||||
|
await createFile({
|
||||||
|
user: authorId,
|
||||||
|
file_id: fileId,
|
||||||
|
filename: `file-${fileId}.txt`,
|
||||||
|
filepath: `/uploads/${fileId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create agent with only first two files attached
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
author: authorId,
|
||||||
|
model: 'gpt-4',
|
||||||
|
provider: 'openai',
|
||||||
|
isCollaborative: true,
|
||||||
|
tool_resources: {
|
||||||
|
file_search: {
|
||||||
|
file_ids: [fileIds[0], fileIds[1]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get or create global project
|
||||||
|
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id');
|
||||||
|
|
||||||
|
// Share agent globally
|
||||||
|
await Agent.updateOne({ id: agentId }, { $push: { projectIds: globalProject._id } });
|
||||||
|
|
||||||
|
// Check access for all files
|
||||||
|
const { hasAccessToFilesViaAgent } = require('./File');
|
||||||
|
const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId);
|
||||||
|
|
||||||
|
// Should have access only to the first two files
|
||||||
|
expect(accessMap.get(fileIds[0])).toBe(true);
|
||||||
|
expect(accessMap.get(fileIds[1])).toBe(true);
|
||||||
|
expect(accessMap.get(fileIds[2])).toBe(false);
|
||||||
|
expect(accessMap.get(fileIds[3])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should grant access to all files when user is the agent author', async () => {
|
||||||
|
const authorId = new mongoose.Types.ObjectId().toString();
|
||||||
|
const agentId = uuidv4();
|
||||||
|
const fileIds = [uuidv4(), uuidv4(), uuidv4()];
|
||||||
|
|
||||||
|
// Create agent
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
author: authorId,
|
||||||
|
model: 'gpt-4',
|
||||||
|
provider: 'openai',
|
||||||
|
tool_resources: {
|
||||||
|
file_search: {
|
||||||
|
file_ids: [fileIds[0]], // Only one file attached
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check access as the author
|
||||||
|
const { hasAccessToFilesViaAgent } = require('./File');
|
||||||
|
const accessMap = await hasAccessToFilesViaAgent(authorId, fileIds, agentId);
|
||||||
|
|
||||||
|
// Author should have access to all files
|
||||||
|
expect(accessMap.get(fileIds[0])).toBe(true);
|
||||||
|
expect(accessMap.get(fileIds[1])).toBe(true);
|
||||||
|
expect(accessMap.get(fileIds[2])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-existent agent gracefully', async () => {
|
||||||
|
const userId = new mongoose.Types.ObjectId().toString();
|
||||||
|
const fileIds = [uuidv4(), uuidv4()];
|
||||||
|
|
||||||
|
const { hasAccessToFilesViaAgent } = require('./File');
|
||||||
|
const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, 'non-existent-agent');
|
||||||
|
|
||||||
|
// Should have no access to any files
|
||||||
|
expect(accessMap.get(fileIds[0])).toBe(false);
|
||||||
|
expect(accessMap.get(fileIds[1])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny access when agent is not collaborative', async () => {
|
||||||
|
const userId = new mongoose.Types.ObjectId().toString();
|
||||||
|
const authorId = new mongoose.Types.ObjectId().toString();
|
||||||
|
const agentId = uuidv4();
|
||||||
|
const fileIds = [uuidv4(), uuidv4()];
|
||||||
|
|
||||||
|
// Create agent with files but isCollaborative: false
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Non-Collaborative Agent',
|
||||||
|
author: authorId,
|
||||||
|
model: 'gpt-4',
|
||||||
|
provider: 'openai',
|
||||||
|
isCollaborative: false,
|
||||||
|
tool_resources: {
|
||||||
|
file_search: {
|
||||||
|
file_ids: fileIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get or create global project
|
||||||
|
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id');
|
||||||
|
|
||||||
|
// Share agent globally
|
||||||
|
await Agent.updateOne({ id: agentId }, { $push: { projectIds: globalProject._id } });
|
||||||
|
|
||||||
|
// Check access for files
|
||||||
|
const { hasAccessToFilesViaAgent } = require('./File');
|
||||||
|
const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId);
|
||||||
|
|
||||||
|
// Should have no access to any files when isCollaborative is false
|
||||||
|
expect(accessMap.get(fileIds[0])).toBe(false);
|
||||||
|
expect(accessMap.get(fileIds[1])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFiles with agent access control', () => {
|
||||||
|
test('should return files owned by user and files accessible through agent', async () => {
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
const userId = new mongoose.Types.ObjectId();
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const ownedFileId = `file_${uuidv4()}`;
|
||||||
|
const sharedFileId = `file_${uuidv4()}`;
|
||||||
|
const inaccessibleFileId = `file_${uuidv4()}`;
|
||||||
|
|
||||||
|
// Create/get global project using getProjectByName which will upsert
|
||||||
|
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME);
|
||||||
|
|
||||||
|
// Create agent with shared file
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Shared Agent',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
projectIds: [globalProject._id],
|
||||||
|
isCollaborative: true,
|
||||||
|
tool_resources: {
|
||||||
|
file_search: {
|
||||||
|
file_ids: [sharedFileId],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create files
|
||||||
|
await createFile({
|
||||||
|
file_id: ownedFileId,
|
||||||
|
user: userId,
|
||||||
|
filename: 'owned.txt',
|
||||||
|
filepath: '/uploads/owned.txt',
|
||||||
|
type: 'text/plain',
|
||||||
|
bytes: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createFile({
|
||||||
|
file_id: sharedFileId,
|
||||||
|
user: authorId,
|
||||||
|
filename: 'shared.txt',
|
||||||
|
filepath: '/uploads/shared.txt',
|
||||||
|
type: 'text/plain',
|
||||||
|
bytes: 200,
|
||||||
|
embedded: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createFile({
|
||||||
|
file_id: inaccessibleFileId,
|
||||||
|
user: authorId,
|
||||||
|
filename: 'inaccessible.txt',
|
||||||
|
filepath: '/uploads/inaccessible.txt',
|
||||||
|
type: 'text/plain',
|
||||||
|
bytes: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get files with access control
|
||||||
|
const files = await getFiles(
|
||||||
|
{ file_id: { $in: [ownedFileId, sharedFileId, inaccessibleFileId] } },
|
||||||
|
null,
|
||||||
|
{ text: 0 },
|
||||||
|
{ userId: userId.toString(), agentId },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(files).toHaveLength(2);
|
||||||
|
expect(files.map((f) => f.file_id)).toContain(ownedFileId);
|
||||||
|
expect(files.map((f) => f.file_id)).toContain(sharedFileId);
|
||||||
|
expect(files.map((f) => f.file_id)).not.toContain(inaccessibleFileId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return all files when no userId/agentId provided', async () => {
|
||||||
|
const userId = new mongoose.Types.ObjectId();
|
||||||
|
const fileId1 = `file_${uuidv4()}`;
|
||||||
|
const fileId2 = `file_${uuidv4()}`;
|
||||||
|
|
||||||
|
await createFile({
|
||||||
|
file_id: fileId1,
|
||||||
|
user: userId,
|
||||||
|
filename: 'file1.txt',
|
||||||
|
filepath: '/uploads/file1.txt',
|
||||||
|
type: 'text/plain',
|
||||||
|
bytes: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createFile({
|
||||||
|
file_id: fileId2,
|
||||||
|
user: new mongoose.Types.ObjectId(),
|
||||||
|
filename: 'file2.txt',
|
||||||
|
filepath: '/uploads/file2.txt',
|
||||||
|
type: 'text/plain',
|
||||||
|
bytes: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = await getFiles({ file_id: { $in: [fileId1, fileId2] } });
|
||||||
|
expect(files).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
const { z } = require('zod');
|
const { z } = require('zod');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { createTempChatExpirationDate } = require('@librechat/api');
|
const { createTempChatExpirationDate } = require('@librechat/api');
|
||||||
const getCustomConfig = require('~/server/services/Config/loadCustomConfig');
|
const getCustomConfig = require('~/server/services/Config/getCustomConfig');
|
||||||
const { Message } = require('~/db/models');
|
const { Message } = require('~/db/models');
|
||||||
|
|
||||||
const idSchema = z.string().uuid();
|
const idSchema = z.string().uuid();
|
||||||
|
|||||||
@@ -135,10 +135,11 @@ const tokenValues = Object.assign(
|
|||||||
'grok-2-1212': { prompt: 2.0, completion: 10.0 },
|
'grok-2-1212': { prompt: 2.0, completion: 10.0 },
|
||||||
'grok-2-latest': { prompt: 2.0, completion: 10.0 },
|
'grok-2-latest': { prompt: 2.0, completion: 10.0 },
|
||||||
'grok-2': { prompt: 2.0, completion: 10.0 },
|
'grok-2': { prompt: 2.0, completion: 10.0 },
|
||||||
'grok-3-mini-fast': { prompt: 0.4, completion: 4 },
|
'grok-3-mini-fast': { prompt: 0.6, completion: 4 },
|
||||||
'grok-3-mini': { prompt: 0.3, completion: 0.5 },
|
'grok-3-mini': { prompt: 0.3, completion: 0.5 },
|
||||||
'grok-3-fast': { prompt: 5.0, completion: 25.0 },
|
'grok-3-fast': { prompt: 5.0, completion: 25.0 },
|
||||||
'grok-3': { prompt: 3.0, completion: 15.0 },
|
'grok-3': { prompt: 3.0, completion: 15.0 },
|
||||||
|
'grok-4': { prompt: 3.0, completion: 15.0 },
|
||||||
'grok-beta': { prompt: 5.0, completion: 15.0 },
|
'grok-beta': { prompt: 5.0, completion: 15.0 },
|
||||||
'mistral-large': { prompt: 2.0, completion: 6.0 },
|
'mistral-large': { prompt: 2.0, completion: 6.0 },
|
||||||
'pixtral-large': { prompt: 2.0, completion: 6.0 },
|
'pixtral-large': { prompt: 2.0, completion: 6.0 },
|
||||||
|
|||||||
@@ -636,6 +636,15 @@ describe('Grok Model Tests - Pricing', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should return correct prompt and completion rates for Grok 4 model', () => {
|
||||||
|
expect(getMultiplier({ model: 'grok-4-0709', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-4'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'grok-4-0709', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-4'].completion,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('should return correct prompt and completion rates for Grok 3 models with prefixes', () => {
|
test('should return correct prompt and completion rates for Grok 3 models with prefixes', () => {
|
||||||
expect(getMultiplier({ model: 'xai/grok-3', tokenType: 'prompt' })).toBe(
|
expect(getMultiplier({ model: 'xai/grok-3', tokenType: 'prompt' })).toBe(
|
||||||
tokenValues['grok-3'].prompt,
|
tokenValues['grok-3'].prompt,
|
||||||
@@ -662,6 +671,15 @@ describe('Grok Model Tests - Pricing', () => {
|
|||||||
tokenValues['grok-3-mini-fast'].completion,
|
tokenValues['grok-3-mini-fast'].completion,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should return correct prompt and completion rates for Grok 4 model with prefixes', () => {
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-4-0709', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-4'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-4-0709', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-4'].completion,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@librechat/backend",
|
"name": "@librechat/backend",
|
||||||
"version": "v0.7.9-rc1",
|
"version": "v0.7.9",
|
||||||
"description": "",
|
"description": "",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "echo 'please run this from the root directory'",
|
"start": "echo 'please run this from the root directory'",
|
||||||
@@ -44,19 +44,21 @@
|
|||||||
"@googleapis/youtube": "^20.0.0",
|
"@googleapis/youtube": "^20.0.0",
|
||||||
"@keyv/redis": "^4.3.3",
|
"@keyv/redis": "^4.3.3",
|
||||||
"@langchain/community": "^0.3.47",
|
"@langchain/community": "^0.3.47",
|
||||||
"@langchain/core": "^0.3.60",
|
"@langchain/core": "^0.3.62",
|
||||||
"@langchain/google-genai": "^0.2.13",
|
"@langchain/google-genai": "^0.2.13",
|
||||||
"@langchain/google-vertexai": "^0.2.13",
|
"@langchain/google-vertexai": "^0.2.13",
|
||||||
|
"@langchain/openai": "^0.5.18",
|
||||||
"@langchain/textsplitters": "^0.1.0",
|
"@langchain/textsplitters": "^0.1.0",
|
||||||
"@librechat/agents": "^2.4.56",
|
"@librechat/agents": "^2.4.68",
|
||||||
"@librechat/api": "*",
|
"@librechat/api": "*",
|
||||||
"@librechat/data-schemas": "*",
|
"@librechat/data-schemas": "*",
|
||||||
"@node-saml/passport-saml": "^5.0.0",
|
"@modelcontextprotocol/sdk": "^1.17.0",
|
||||||
|
"@node-saml/passport-saml": "^5.1.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",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.8.1",
|
||||||
"connect-redis": "^7.1.0",
|
"connect-redis": "^8.1.0",
|
||||||
"cookie": "^0.7.2",
|
"cookie": "^0.7.2",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@@ -66,10 +68,11 @@
|
|||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-mongo-sanitize": "^2.2.0",
|
"express-mongo-sanitize": "^2.2.0",
|
||||||
"express-rate-limit": "^7.4.1",
|
"express-rate-limit": "^7.4.1",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.2",
|
||||||
"express-static-gzip": "^2.2.0",
|
"express-static-gzip": "^2.2.0",
|
||||||
"file-type": "^18.7.0",
|
"file-type": "^18.7.0",
|
||||||
"firebase": "^11.0.2",
|
"firebase": "^11.0.2",
|
||||||
|
"form-data": "^4.0.4",
|
||||||
"googleapis": "^126.0.1",
|
"googleapis": "^126.0.1",
|
||||||
"handlebars": "^4.7.7",
|
"handlebars": "^4.7.7",
|
||||||
"https-proxy-agent": "^7.0.6",
|
"https-proxy-agent": "^7.0.6",
|
||||||
@@ -87,12 +90,12 @@
|
|||||||
"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": "^2.0.1",
|
"multer": "^2.0.2",
|
||||||
"nanoid": "^3.3.7",
|
"nanoid": "^3.3.7",
|
||||||
"node-fetch": "^2.7.0",
|
"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": "^5.10.1",
|
||||||
"openai-chat-tokens": "^0.2.8",
|
"openai-chat-tokens": "^0.2.8",
|
||||||
"openid-client": "^6.5.0",
|
"openid-client": "^6.5.0",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
@@ -117,7 +120,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"mongodb-memory-server": "^10.1.3",
|
"mongodb-memory-server": "^10.1.4",
|
||||||
"nodemon": "^3.0.3",
|
"nodemon": "^3.0.3",
|
||||||
"supertest": "^7.1.0"
|
"supertest": "^7.1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { CacheKeys, AuthType } = require('librechat-data-provider');
|
const { CacheKeys, AuthType, Constants } = require('librechat-data-provider');
|
||||||
const { getCustomConfig, getCachedTools } = require('~/server/services/Config');
|
const { getCustomConfig, getCachedTools } = require('~/server/services/Config');
|
||||||
const { getToolkitKey } = require('~/server/services/ToolService');
|
const { getToolkitKey } = require('~/server/services/ToolService');
|
||||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||||
const { availableTools } = require('~/app/clients/tools');
|
const { availableTools } = require('~/app/clients/tools');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
const { Constants } = require('librechat-data-provider');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters out duplicate plugins from the list of plugins.
|
* Filters out duplicate plugins from the list of plugins.
|
||||||
@@ -139,15 +138,21 @@ function createGetServerTools() {
|
|||||||
*/
|
*/
|
||||||
const getAvailableTools = async (req, res) => {
|
const getAvailableTools = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
const customConfig = await getCustomConfig();
|
||||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||||
const cachedTools = await cache.get(CacheKeys.TOOLS);
|
const cachedToolsArray = await cache.get(CacheKeys.TOOLS);
|
||||||
if (cachedTools) {
|
const cachedUserTools = await getCachedTools({ userId });
|
||||||
res.status(200).json(cachedTools);
|
const userPlugins = convertMCPToolsToPlugins(cachedUserTools, customConfig);
|
||||||
|
|
||||||
|
if (cachedToolsArray && userPlugins) {
|
||||||
|
const dedupedTools = filterUniquePlugins([...userPlugins, ...cachedToolsArray]);
|
||||||
|
res.status(200).json(dedupedTools);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If not in cache, build from manifest
|
||||||
let pluginManifest = availableTools;
|
let pluginManifest = availableTools;
|
||||||
const customConfig = await getCustomConfig();
|
|
||||||
if (customConfig?.mcpServers != null) {
|
if (customConfig?.mcpServers != null) {
|
||||||
const mcpManager = getMCPManager();
|
const mcpManager = getMCPManager();
|
||||||
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||||
@@ -173,7 +178,7 @@ const getAvailableTools = async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const toolDefinitions = await getCachedTools({ includeGlobal: true });
|
const toolDefinitions = (await getCachedTools({ includeGlobal: true })) || {};
|
||||||
|
|
||||||
const toolsOutput = [];
|
const toolsOutput = [];
|
||||||
for (const plugin of authenticatedPlugins) {
|
for (const plugin of authenticatedPlugins) {
|
||||||
@@ -218,16 +223,70 @@ const getAvailableTools = async (req, res) => {
|
|||||||
|
|
||||||
toolsOutput.push(toolToAdd);
|
toolsOutput.push(toolToAdd);
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalTools = filterUniquePlugins(toolsOutput);
|
const finalTools = filterUniquePlugins(toolsOutput);
|
||||||
await cache.set(CacheKeys.TOOLS, finalTools);
|
await cache.set(CacheKeys.TOOLS, finalTools);
|
||||||
res.status(200).json(finalTools);
|
|
||||||
|
const dedupedTools = filterUniquePlugins([...userPlugins, ...finalTools]);
|
||||||
|
|
||||||
|
res.status(200).json(dedupedTools);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[getAvailableTools]', error);
|
logger.error('[getAvailableTools]', error);
|
||||||
res.status(500).json({ message: error.message });
|
res.status(500).json({ message: error.message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts MCP function format tools to plugin format
|
||||||
|
* @param {Object} functionTools - Object with function format tools
|
||||||
|
* @param {Object} customConfig - Custom configuration for MCP servers
|
||||||
|
* @returns {Array} Array of plugin objects
|
||||||
|
*/
|
||||||
|
function convertMCPToolsToPlugins(functionTools, customConfig) {
|
||||||
|
const plugins = [];
|
||||||
|
|
||||||
|
for (const [toolKey, toolData] of Object.entries(functionTools)) {
|
||||||
|
if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const functionData = toolData.function;
|
||||||
|
const parts = toolKey.split(Constants.mcp_delimiter);
|
||||||
|
const serverName = parts[parts.length - 1];
|
||||||
|
|
||||||
|
const serverConfig = customConfig?.mcpServers?.[serverName];
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
name: parts[0], // Use the tool name without server suffix
|
||||||
|
pluginKey: toolKey,
|
||||||
|
description: functionData.description || '',
|
||||||
|
authenticated: true,
|
||||||
|
icon: serverConfig?.iconPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build authConfig for MCP tools
|
||||||
|
if (!serverConfig?.customUserVars) {
|
||||||
|
plugin.authConfig = [];
|
||||||
|
plugins.push(plugin);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customVarKeys = Object.keys(serverConfig.customUserVars);
|
||||||
|
if (customVarKeys.length === 0) {
|
||||||
|
plugin.authConfig = [];
|
||||||
|
} else {
|
||||||
|
plugin.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
|
||||||
|
authField: key,
|
||||||
|
label: value.title || key,
|
||||||
|
description: value.description || '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins.push(plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
return plugins;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getAvailableTools,
|
getAvailableTools,
|
||||||
getAvailablePluginsController,
|
getAvailablePluginsController,
|
||||||
|
|||||||
89
api/server/controllers/PluginController.spec.js
Normal file
89
api/server/controllers/PluginController.spec.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
const { Constants } = require('librechat-data-provider');
|
||||||
|
const { getCustomConfig, getCachedTools } = require('~/server/services/Config');
|
||||||
|
const { getLogStores } = require('~/cache');
|
||||||
|
|
||||||
|
// Mock the dependencies
|
||||||
|
jest.mock('@librechat/data-schemas', () => ({
|
||||||
|
logger: {
|
||||||
|
debug: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Config', () => ({
|
||||||
|
getCustomConfig: jest.fn(),
|
||||||
|
getCachedTools: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/ToolService', () => ({
|
||||||
|
getToolkitKey: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/config', () => ({
|
||||||
|
getMCPManager: jest.fn(() => ({
|
||||||
|
loadManifestTools: jest.fn().mockResolvedValue([]),
|
||||||
|
})),
|
||||||
|
getFlowStateManager: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/app/clients/tools', () => ({
|
||||||
|
availableTools: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/cache', () => ({
|
||||||
|
getLogStores: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import the actual module with the function we want to test
|
||||||
|
const { getAvailableTools } = require('./PluginController');
|
||||||
|
|
||||||
|
describe('PluginController', () => {
|
||||||
|
describe('plugin.icon behavior', () => {
|
||||||
|
let mockReq, mockRes, mockCache;
|
||||||
|
|
||||||
|
const callGetAvailableToolsWithMCPServer = async (mcpServers) => {
|
||||||
|
mockCache.get.mockResolvedValue(null);
|
||||||
|
getCustomConfig.mockResolvedValue({ mcpServers });
|
||||||
|
|
||||||
|
const functionTools = {
|
||||||
|
[`test-tool${Constants.mcp_delimiter}test-server`]: {
|
||||||
|
function: { name: 'test-tool', description: 'A test tool' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
getCachedTools.mockResolvedValueOnce(functionTools);
|
||||||
|
getCachedTools.mockResolvedValueOnce({
|
||||||
|
[`test-tool${Constants.mcp_delimiter}test-server`]: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await getAvailableTools(mockReq, mockRes);
|
||||||
|
const responseData = mockRes.json.mock.calls[0][0];
|
||||||
|
return responseData.find((tool) => tool.name === 'test-tool');
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockReq = { user: { id: 'test-user-id' } };
|
||||||
|
mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
|
||||||
|
mockCache = { get: jest.fn(), set: jest.fn() };
|
||||||
|
getLogStores.mockReturnValue(mockCache);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set plugin.icon when iconPath is defined', async () => {
|
||||||
|
const mcpServers = {
|
||||||
|
'test-server': {
|
||||||
|
iconPath: '/path/to/icon.png',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const testTool = await callGetAvailableToolsWithMCPServer(mcpServers);
|
||||||
|
expect(testTool.icon).toBe('/path/to/icon.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set plugin.icon to undefined when iconPath is not defined', async () => {
|
||||||
|
const mcpServers = {
|
||||||
|
'test-server': {},
|
||||||
|
};
|
||||||
|
const testTool = await callGetAvailableToolsWithMCPServer(mcpServers);
|
||||||
|
expect(testTool.icon).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
const {
|
|
||||||
Tools,
|
|
||||||
Constants,
|
|
||||||
FileSources,
|
|
||||||
webSearchKeys,
|
|
||||||
extractWebSearchEnvVars,
|
|
||||||
} = require('librechat-data-provider');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const { webSearchKeys, extractWebSearchEnvVars } = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
getFiles,
|
getFiles,
|
||||||
updateUser,
|
updateUser,
|
||||||
@@ -20,6 +14,7 @@ const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/service
|
|||||||
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('~/server/services/AuthService');
|
||||||
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
|
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
|
||||||
|
const { Tools, Constants, FileSources } = require('librechat-data-provider');
|
||||||
const { processDeleteRequest } = require('~/server/services/Files/process');
|
const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||||
const { Transaction, Balance, User } = require('~/db/models');
|
const { Transaction, Balance, User } = require('~/db/models');
|
||||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||||
@@ -180,14 +175,16 @@ const updateUserPluginsController = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const mcpManager = getMCPManager(user.id);
|
const mcpManager = getMCPManager(user.id);
|
||||||
if (mcpManager) {
|
if (mcpManager) {
|
||||||
|
// Extract server name from pluginKey (format: "mcp_<serverName>")
|
||||||
|
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
|
||||||
logger.info(
|
logger.info(
|
||||||
`[updateUserPluginsController] Disconnecting MCP connections for user ${user.id} after plugin auth update for ${pluginKey}.`,
|
`[updateUserPluginsController] Disconnecting MCP server ${serverName} for user ${user.id} after plugin auth update for ${pluginKey}.`,
|
||||||
);
|
);
|
||||||
await mcpManager.disconnectUserConnections(user.id);
|
await mcpManager.disconnectUserConnection(user.id, serverName);
|
||||||
}
|
}
|
||||||
} catch (disconnectError) {
|
} catch (disconnectError) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`[updateUserPluginsController] Error disconnecting MCP connections for user ${user.id} after plugin auth update:`,
|
`[updateUserPluginsController] Error disconnecting MCP connection for user ${user.id} after plugin auth update:`,
|
||||||
disconnectError,
|
disconnectError,
|
||||||
);
|
);
|
||||||
// Do not fail the request for this, but log it.
|
// Do not fail the request for this, but log it.
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
require('events').EventEmitter.defaultMaxListeners = 100;
|
require('events').EventEmitter.defaultMaxListeners = 100;
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const { DynamicStructuredTool } = require('@langchain/core/tools');
|
||||||
|
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
|
||||||
const {
|
const {
|
||||||
sendEvent,
|
sendEvent,
|
||||||
createRun,
|
createRun,
|
||||||
Tokenizer,
|
Tokenizer,
|
||||||
checkAccess,
|
checkAccess,
|
||||||
memoryInstructions,
|
memoryInstructions,
|
||||||
|
formatContentStrings,
|
||||||
createMemoryProcessor,
|
createMemoryProcessor,
|
||||||
} = require('@librechat/api');
|
} = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
Callback,
|
Callback,
|
||||||
Providers,
|
Providers,
|
||||||
GraphEvents,
|
GraphEvents,
|
||||||
|
TitleMethod,
|
||||||
formatMessage,
|
formatMessage,
|
||||||
formatAgentMessages,
|
formatAgentMessages,
|
||||||
formatContentStrings,
|
|
||||||
getTokenCountForMessage,
|
getTokenCountForMessage,
|
||||||
createMetadataAggregator,
|
createMetadataAggregator,
|
||||||
} = require('@librechat/agents');
|
} = require('@librechat/agents');
|
||||||
@@ -24,20 +27,22 @@ const {
|
|||||||
VisionModes,
|
VisionModes,
|
||||||
ContentTypes,
|
ContentTypes,
|
||||||
EModelEndpoint,
|
EModelEndpoint,
|
||||||
KnownEndpoints,
|
|
||||||
PermissionTypes,
|
PermissionTypes,
|
||||||
isAgentsEndpoint,
|
isAgentsEndpoint,
|
||||||
AgentCapabilities,
|
AgentCapabilities,
|
||||||
bedrockInputSchema,
|
bedrockInputSchema,
|
||||||
removeNullishValues,
|
removeNullishValues,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { DynamicStructuredTool } = require('@langchain/core/tools');
|
const {
|
||||||
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
|
findPluginAuthsByKeys,
|
||||||
const { createGetMCPAuthMap, checkCapability } = require('~/server/services/Config');
|
getFormattedMemories,
|
||||||
|
deleteMemory,
|
||||||
|
setMemory,
|
||||||
|
} = require('~/models');
|
||||||
|
const { getMCPAuthMap, checkCapability, hasCustomUserVars } = 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 { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
|
||||||
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
||||||
const { getFormattedMemories, deleteMemory, setMemory } = require('~/models');
|
|
||||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||||
const { getProviderConfig } = require('~/server/services/Endpoints');
|
const { getProviderConfig } = require('~/server/services/Endpoints');
|
||||||
const BaseClient = require('~/app/clients/BaseClient');
|
const BaseClient = require('~/app/clients/BaseClient');
|
||||||
@@ -54,6 +59,7 @@ const omitTitleOptions = new Set([
|
|||||||
'thinkingBudget',
|
'thinkingBudget',
|
||||||
'includeThoughts',
|
'includeThoughts',
|
||||||
'maxOutputTokens',
|
'maxOutputTokens',
|
||||||
|
'additionalModelRequestFields',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,13 +71,15 @@ const payloadParser = ({ req, agent, endpoint }) => {
|
|||||||
if (isAgentsEndpoint(endpoint)) {
|
if (isAgentsEndpoint(endpoint)) {
|
||||||
return { model: undefined };
|
return { model: undefined };
|
||||||
} else if (endpoint === EModelEndpoint.bedrock) {
|
} else if (endpoint === EModelEndpoint.bedrock) {
|
||||||
return bedrockInputSchema.parse(agent.model_parameters);
|
const parsedValues = bedrockInputSchema.parse(agent.model_parameters);
|
||||||
|
if (parsedValues.thinking == null) {
|
||||||
|
parsedValues.thinking = false;
|
||||||
|
}
|
||||||
|
return parsedValues;
|
||||||
}
|
}
|
||||||
return req.body.endpointOption.model_parameters;
|
return req.body.endpointOption.model_parameters;
|
||||||
};
|
};
|
||||||
|
|
||||||
const legacyContentEndpoints = new Set([KnownEndpoints.groq, KnownEndpoints.deepseek]);
|
|
||||||
|
|
||||||
const noSystemModelRegex = [/\b(o1-preview|o1-mini|amazon\.titan-text)\b/gi];
|
const noSystemModelRegex = [/\b(o1-preview|o1-mini|amazon\.titan-text)\b/gi];
|
||||||
|
|
||||||
function createTokenCounter(encoding) {
|
function createTokenCounter(encoding) {
|
||||||
@@ -452,6 +460,12 @@ class AgentClient extends BaseClient {
|
|||||||
res: this.options.res,
|
res: this.options.res,
|
||||||
agent: prelimAgent,
|
agent: prelimAgent,
|
||||||
allowedProviders,
|
allowedProviders,
|
||||||
|
endpointOption: {
|
||||||
|
endpoint:
|
||||||
|
prelimAgent.id !== Constants.EPHEMERAL_AGENT_ID
|
||||||
|
? EModelEndpoint.agents
|
||||||
|
: memoryConfig.agent?.provider,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
@@ -700,17 +714,12 @@ class AgentClient extends BaseClient {
|
|||||||
version: 'v2',
|
version: 'v2',
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUserMCPAuthMap = await createGetMCPAuthMap();
|
|
||||||
|
|
||||||
const toolSet = new Set((this.options.agent.tools ?? []).map((tool) => tool && tool.name));
|
const toolSet = new Set((this.options.agent.tools ?? []).map((tool) => tool && tool.name));
|
||||||
let { messages: initialMessages, indexTokenCountMap } = formatAgentMessages(
|
let { messages: initialMessages, indexTokenCountMap } = formatAgentMessages(
|
||||||
payload,
|
payload,
|
||||||
this.indexTokenCountMap,
|
this.indexTokenCountMap,
|
||||||
toolSet,
|
toolSet,
|
||||||
);
|
);
|
||||||
if (legacyContentEndpoints.has(this.options.agent.endpoint?.toLowerCase())) {
|
|
||||||
initialMessages = formatContentStrings(initialMessages);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@@ -726,6 +735,9 @@ class AgentClient extends BaseClient {
|
|||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
this.model = agent.model_parameters.model;
|
this.model = agent.model_parameters.model;
|
||||||
}
|
}
|
||||||
|
if (i > 0 && config.signal == null) {
|
||||||
|
config.signal = abortController.signal;
|
||||||
|
}
|
||||||
if (agent.recursion_limit && typeof agent.recursion_limit === 'number') {
|
if (agent.recursion_limit && typeof agent.recursion_limit === 'number') {
|
||||||
config.recursionLimit = agent.recursion_limit;
|
config.recursionLimit = agent.recursion_limit;
|
||||||
}
|
}
|
||||||
@@ -774,6 +786,9 @@ class AgentClient extends BaseClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let messages = _messages;
|
let messages = _messages;
|
||||||
|
if (agent.useLegacyContent === true) {
|
||||||
|
messages = formatContentStrings(messages);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
agent.model_parameters?.clientOptions?.defaultHeaders?.['anthropic-beta']?.includes(
|
agent.model_parameters?.clientOptions?.defaultHeaders?.['anthropic-beta']?.includes(
|
||||||
'prompt-caching',
|
'prompt-caching',
|
||||||
@@ -822,10 +837,11 @@ class AgentClient extends BaseClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (getUserMCPAuthMap) {
|
if (await hasCustomUserVars()) {
|
||||||
config.configurable.userMCPAuthMap = await getUserMCPAuthMap({
|
config.configurable.userMCPAuthMap = await getMCPAuthMap({
|
||||||
tools: agent.tools,
|
tools: agent.tools,
|
||||||
userId: this.options.req.user.id,
|
userId: this.options.req.user.id,
|
||||||
|
findPluginAuthsByKeys,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1001,25 +1017,40 @@ class AgentClient extends BaseClient {
|
|||||||
}
|
}
|
||||||
const { handleLLMEnd, collected: collectedMetadata } = createMetadataAggregator();
|
const { handleLLMEnd, collected: collectedMetadata } = createMetadataAggregator();
|
||||||
const { req, res, agent } = this.options;
|
const { req, res, agent } = this.options;
|
||||||
const endpoint = agent.endpoint;
|
let endpoint = agent.endpoint;
|
||||||
|
|
||||||
/** @type {import('@librechat/agents').ClientOptions} */
|
/** @type {import('@librechat/agents').ClientOptions} */
|
||||||
let clientOptions = {
|
let clientOptions = {
|
||||||
maxTokens: 75,
|
maxTokens: 75,
|
||||||
model: agent.model_parameters.model,
|
model: agent.model || agent.model_parameters.model,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { getOptions, overrideProvider, customEndpointConfig } =
|
let titleProviderConfig = await getProviderConfig(endpoint);
|
||||||
await getProviderConfig(endpoint);
|
|
||||||
|
|
||||||
/** @type {TEndpoint | undefined} */
|
/** @type {TEndpoint | undefined} */
|
||||||
const endpointConfig = req.app.locals[endpoint] ?? customEndpointConfig;
|
const endpointConfig =
|
||||||
|
req.app.locals.all ?? req.app.locals[endpoint] ?? titleProviderConfig.customEndpointConfig;
|
||||||
if (!endpointConfig) {
|
if (!endpointConfig) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'[api/server/controllers/agents/client.js #titleConvo] Error getting endpoint config',
|
'[api/server/controllers/agents/client.js #titleConvo] Error getting endpoint config',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (endpointConfig?.titleEndpoint && endpointConfig.titleEndpoint !== endpoint) {
|
||||||
|
try {
|
||||||
|
titleProviderConfig = await getProviderConfig(endpointConfig.titleEndpoint);
|
||||||
|
endpoint = endpointConfig.titleEndpoint;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`[api/server/controllers/agents/client.js #titleConvo] Error getting title endpoint config for ${endpointConfig.titleEndpoint}, falling back to default`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
// Fall back to original provider config
|
||||||
|
endpoint = agent.endpoint;
|
||||||
|
titleProviderConfig = await getProviderConfig(endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
endpointConfig &&
|
endpointConfig &&
|
||||||
endpointConfig.titleModel &&
|
endpointConfig.titleModel &&
|
||||||
@@ -1028,7 +1059,7 @@ class AgentClient extends BaseClient {
|
|||||||
clientOptions.model = endpointConfig.titleModel;
|
clientOptions.model = endpointConfig.titleModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = await getOptions({
|
const options = await titleProviderConfig.getOptions({
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
optionsOnly: true,
|
optionsOnly: true,
|
||||||
@@ -1037,12 +1068,18 @@ class AgentClient extends BaseClient {
|
|||||||
endpointOption: { model_parameters: clientOptions },
|
endpointOption: { model_parameters: clientOptions },
|
||||||
});
|
});
|
||||||
|
|
||||||
let provider = options.provider ?? overrideProvider ?? agent.provider;
|
let provider = options.provider ?? titleProviderConfig.overrideProvider ?? agent.provider;
|
||||||
if (
|
if (
|
||||||
endpoint === EModelEndpoint.azureOpenAI &&
|
endpoint === EModelEndpoint.azureOpenAI &&
|
||||||
options.llmConfig?.azureOpenAIApiInstanceName == null
|
options.llmConfig?.azureOpenAIApiInstanceName == null
|
||||||
) {
|
) {
|
||||||
provider = Providers.OPENAI;
|
provider = Providers.OPENAI;
|
||||||
|
} else if (
|
||||||
|
endpoint === EModelEndpoint.azureOpenAI &&
|
||||||
|
options.llmConfig?.azureOpenAIApiInstanceName != null &&
|
||||||
|
provider !== Providers.AZURE
|
||||||
|
) {
|
||||||
|
provider = Providers.AZURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {import('@librechat/agents').ClientOptions} */
|
/** @type {import('@librechat/agents').ClientOptions} */
|
||||||
@@ -1064,16 +1101,23 @@ class AgentClient extends BaseClient {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (provider === Providers.GOOGLE) {
|
if (
|
||||||
|
provider === Providers.GOOGLE &&
|
||||||
|
(endpointConfig?.titleMethod === TitleMethod.FUNCTIONS ||
|
||||||
|
endpointConfig?.titleMethod === TitleMethod.STRUCTURED)
|
||||||
|
) {
|
||||||
clientOptions.json = true;
|
clientOptions.json = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const titleResult = await this.run.generateTitle({
|
const titleResult = await this.run.generateTitle({
|
||||||
provider,
|
provider,
|
||||||
|
clientOptions,
|
||||||
inputText: text,
|
inputText: text,
|
||||||
contentParts: this.contentParts,
|
contentParts: this.contentParts,
|
||||||
clientOptions,
|
titleMethod: endpointConfig?.titleMethod,
|
||||||
|
titlePrompt: endpointConfig?.titlePrompt,
|
||||||
|
titlePromptTemplate: endpointConfig?.titlePromptTemplate,
|
||||||
chainOptions: {
|
chainOptions: {
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
callbacks: [
|
callbacks: [
|
||||||
@@ -1121,8 +1165,52 @@ class AgentClient extends BaseClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Silent method, as `recordCollectedUsage` is used instead */
|
/**
|
||||||
async recordTokenUsage() {}
|
* @param {object} params
|
||||||
|
* @param {number} params.promptTokens
|
||||||
|
* @param {number} params.completionTokens
|
||||||
|
* @param {OpenAIUsageMetadata} [params.usage]
|
||||||
|
* @param {string} [params.model]
|
||||||
|
* @param {string} [params.context='message']
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async recordTokenUsage({ model, promptTokens, completionTokens, usage, context = 'message' }) {
|
||||||
|
try {
|
||||||
|
await spendTokens(
|
||||||
|
{
|
||||||
|
model,
|
||||||
|
context,
|
||||||
|
conversationId: this.conversationId,
|
||||||
|
user: this.user ?? this.options.req.user?.id,
|
||||||
|
endpointTokenConfig: this.options.endpointTokenConfig,
|
||||||
|
},
|
||||||
|
{ promptTokens, completionTokens },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
usage &&
|
||||||
|
typeof usage === 'object' &&
|
||||||
|
'reasoning_tokens' in usage &&
|
||||||
|
typeof usage.reasoning_tokens === 'number'
|
||||||
|
) {
|
||||||
|
await spendTokens(
|
||||||
|
{
|
||||||
|
model,
|
||||||
|
context: 'reasoning',
|
||||||
|
conversationId: this.conversationId,
|
||||||
|
user: this.user ?? this.options.req.user?.id,
|
||||||
|
endpointTokenConfig: this.options.endpointTokenConfig,
|
||||||
|
},
|
||||||
|
{ completionTokens: usage.reasoning_tokens },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
'[api/server/controllers/agents/client.js #recordTokenUsage] Error recording token usage',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getEncoding() {
|
getEncoding() {
|
||||||
return 'o200k_base';
|
return 'o200k_base';
|
||||||
|
|||||||
730
api/server/controllers/agents/client.test.js
Normal file
730
api/server/controllers/agents/client.test.js
Normal file
@@ -0,0 +1,730 @@
|
|||||||
|
const { Providers } = require('@librechat/agents');
|
||||||
|
const { Constants, EModelEndpoint } = require('librechat-data-provider');
|
||||||
|
const AgentClient = require('./client');
|
||||||
|
|
||||||
|
jest.mock('@librechat/agents', () => ({
|
||||||
|
...jest.requireActual('@librechat/agents'),
|
||||||
|
createMetadataAggregator: () => ({
|
||||||
|
handleLLMEnd: jest.fn(),
|
||||||
|
collected: [],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AgentClient - titleConvo', () => {
|
||||||
|
let client;
|
||||||
|
let mockRun;
|
||||||
|
let mockReq;
|
||||||
|
let mockRes;
|
||||||
|
let mockAgent;
|
||||||
|
let mockOptions;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset all mocks
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Mock run object
|
||||||
|
mockRun = {
|
||||||
|
generateTitle: jest.fn().mockResolvedValue({
|
||||||
|
title: 'Generated Title',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock agent - with both endpoint and provider
|
||||||
|
mockAgent = {
|
||||||
|
id: 'agent-123',
|
||||||
|
endpoint: EModelEndpoint.openAI, // Use a valid provider as endpoint for getProviderConfig
|
||||||
|
provider: EModelEndpoint.openAI, // Add provider property
|
||||||
|
model_parameters: {
|
||||||
|
model: 'gpt-4',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock request and response
|
||||||
|
mockReq = {
|
||||||
|
app: {
|
||||||
|
locals: {
|
||||||
|
[EModelEndpoint.openAI]: {
|
||||||
|
// Match the agent endpoint
|
||||||
|
titleModel: 'gpt-3.5-turbo',
|
||||||
|
titlePrompt: 'Custom title prompt',
|
||||||
|
titleMethod: 'structured',
|
||||||
|
titlePromptTemplate: 'Template: {{content}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: 'user-123',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
model: 'gpt-4',
|
||||||
|
endpoint: EModelEndpoint.openAI,
|
||||||
|
key: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRes = {};
|
||||||
|
|
||||||
|
// Mock options
|
||||||
|
mockOptions = {
|
||||||
|
req: mockReq,
|
||||||
|
res: mockRes,
|
||||||
|
agent: mockAgent,
|
||||||
|
endpointTokenConfig: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create client instance
|
||||||
|
client = new AgentClient(mockOptions);
|
||||||
|
client.run = mockRun;
|
||||||
|
client.responseMessageId = 'response-123';
|
||||||
|
client.conversationId = 'convo-123';
|
||||||
|
client.contentParts = [{ type: 'text', text: 'Test content' }];
|
||||||
|
client.recordCollectedUsage = jest.fn().mockResolvedValue(); // Mock as async function that resolves
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('titleConvo method', () => {
|
||||||
|
it('should throw error if run is not initialized', async () => {
|
||||||
|
client.run = null;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.titleConvo({ text: 'Test', abortController: new AbortController() }),
|
||||||
|
).rejects.toThrow('Run not initialized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use titlePrompt from endpoint config', async () => {
|
||||||
|
const text = 'Test conversation text';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
titlePrompt: 'Custom title prompt',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use titlePromptTemplate from endpoint config', async () => {
|
||||||
|
const text = 'Test conversation text';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
titlePromptTemplate: 'Template: {{content}}',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use titleMethod from endpoint config', async () => {
|
||||||
|
const text = 'Test conversation text';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: Providers.OPENAI,
|
||||||
|
titleMethod: 'structured',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use titleModel from endpoint config when provided', async () => {
|
||||||
|
const text = 'Test conversation text';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
// Check that generateTitle was called with correct clientOptions
|
||||||
|
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
|
||||||
|
expect(generateTitleCall.clientOptions.model).toBe('gpt-3.5-turbo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing endpoint config gracefully', async () => {
|
||||||
|
// Remove endpoint config
|
||||||
|
mockReq.app.locals[EModelEndpoint.openAI] = undefined;
|
||||||
|
|
||||||
|
const text = 'Test conversation text';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
titlePrompt: undefined,
|
||||||
|
titlePromptTemplate: undefined,
|
||||||
|
titleMethod: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use agent model when titleModel is not provided', async () => {
|
||||||
|
// Remove titleModel from config
|
||||||
|
delete mockReq.app.locals[EModelEndpoint.openAI].titleModel;
|
||||||
|
|
||||||
|
const text = 'Test conversation text';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
|
||||||
|
expect(generateTitleCall.clientOptions.model).toBe('gpt-4'); // Should use agent's model
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not use titleModel when it equals CURRENT_MODEL constant', async () => {
|
||||||
|
mockReq.app.locals[EModelEndpoint.openAI].titleModel = Constants.CURRENT_MODEL;
|
||||||
|
|
||||||
|
const text = 'Test conversation text';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
|
||||||
|
expect(generateTitleCall.clientOptions.model).toBe('gpt-4'); // Should use agent's model
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass all required parameters to generateTitle', async () => {
|
||||||
|
const text = 'Test conversation text';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
expect(mockRun.generateTitle).toHaveBeenCalledWith({
|
||||||
|
provider: expect.any(String),
|
||||||
|
inputText: text,
|
||||||
|
contentParts: client.contentParts,
|
||||||
|
clientOptions: expect.objectContaining({
|
||||||
|
model: 'gpt-3.5-turbo',
|
||||||
|
}),
|
||||||
|
titlePrompt: 'Custom title prompt',
|
||||||
|
titlePromptTemplate: 'Template: {{content}}',
|
||||||
|
titleMethod: 'structured',
|
||||||
|
chainOptions: expect.objectContaining({
|
||||||
|
signal: abortController.signal,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should record collected usage after title generation', async () => {
|
||||||
|
const text = 'Test conversation text';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
expect(client.recordCollectedUsage).toHaveBeenCalledWith({
|
||||||
|
model: 'gpt-3.5-turbo',
|
||||||
|
context: 'title',
|
||||||
|
collectedUsage: expect.any(Array),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the generated title', async () => {
|
||||||
|
const text = 'Test conversation text';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
const result = await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
expect(result).toBe('Generated Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle errors gracefully and return undefined', async () => {
|
||||||
|
mockRun.generateTitle.mockRejectedValue(new Error('Title generation failed'));
|
||||||
|
|
||||||
|
const text = 'Test conversation text';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
const result = await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass titleEndpoint configuration to generateTitle', async () => {
|
||||||
|
// Mock the API key just for this test
|
||||||
|
const originalApiKey = process.env.ANTHROPIC_API_KEY;
|
||||||
|
process.env.ANTHROPIC_API_KEY = 'test-api-key';
|
||||||
|
|
||||||
|
// Add titleEndpoint to the config
|
||||||
|
mockReq.app.locals[EModelEndpoint.openAI].titleEndpoint = EModelEndpoint.anthropic;
|
||||||
|
mockReq.app.locals[EModelEndpoint.openAI].titleMethod = 'structured';
|
||||||
|
mockReq.app.locals[EModelEndpoint.openAI].titlePrompt = 'Custom title prompt';
|
||||||
|
mockReq.app.locals[EModelEndpoint.openAI].titlePromptTemplate = 'Custom template';
|
||||||
|
|
||||||
|
const text = 'Test conversation text';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
// Verify generateTitle was called with the custom configuration
|
||||||
|
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
titleMethod: 'structured',
|
||||||
|
provider: Providers.ANTHROPIC,
|
||||||
|
titlePrompt: 'Custom title prompt',
|
||||||
|
titlePromptTemplate: 'Custom template',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Restore the original API key
|
||||||
|
if (originalApiKey) {
|
||||||
|
process.env.ANTHROPIC_API_KEY = originalApiKey;
|
||||||
|
} else {
|
||||||
|
delete process.env.ANTHROPIC_API_KEY;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use all config when endpoint config is missing', async () => {
|
||||||
|
// Remove endpoint-specific config
|
||||||
|
delete mockReq.app.locals[EModelEndpoint.openAI].titleModel;
|
||||||
|
delete mockReq.app.locals[EModelEndpoint.openAI].titlePrompt;
|
||||||
|
delete mockReq.app.locals[EModelEndpoint.openAI].titleMethod;
|
||||||
|
delete mockReq.app.locals[EModelEndpoint.openAI].titlePromptTemplate;
|
||||||
|
|
||||||
|
// Set 'all' config
|
||||||
|
mockReq.app.locals.all = {
|
||||||
|
titleModel: 'gpt-4o-mini',
|
||||||
|
titlePrompt: 'All config title prompt',
|
||||||
|
titleMethod: 'completion',
|
||||||
|
titlePromptTemplate: 'All config template: {{content}}',
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = 'Test conversation text';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
// Verify generateTitle was called with 'all' config values
|
||||||
|
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
titleMethod: 'completion',
|
||||||
|
titlePrompt: 'All config title prompt',
|
||||||
|
titlePromptTemplate: 'All config template: {{content}}',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check that the model was set from 'all' config
|
||||||
|
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
|
||||||
|
expect(generateTitleCall.clientOptions.model).toBe('gpt-4o-mini');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prioritize all config over endpoint config for title settings', async () => {
|
||||||
|
// Set both endpoint and 'all' config
|
||||||
|
mockReq.app.locals[EModelEndpoint.openAI].titleModel = 'gpt-3.5-turbo';
|
||||||
|
mockReq.app.locals[EModelEndpoint.openAI].titlePrompt = 'Endpoint title prompt';
|
||||||
|
mockReq.app.locals[EModelEndpoint.openAI].titleMethod = 'structured';
|
||||||
|
// Remove titlePromptTemplate from endpoint config to test fallback
|
||||||
|
delete mockReq.app.locals[EModelEndpoint.openAI].titlePromptTemplate;
|
||||||
|
|
||||||
|
mockReq.app.locals.all = {
|
||||||
|
titleModel: 'gpt-4o-mini',
|
||||||
|
titlePrompt: 'All config title prompt',
|
||||||
|
titleMethod: 'completion',
|
||||||
|
titlePromptTemplate: 'All config template',
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = 'Test conversation text';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
// Verify 'all' config takes precedence over endpoint config
|
||||||
|
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
titleMethod: 'completion',
|
||||||
|
titlePrompt: 'All config title prompt',
|
||||||
|
titlePromptTemplate: 'All config template',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check that the model was set from 'all' config
|
||||||
|
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
|
||||||
|
expect(generateTitleCall.clientOptions.model).toBe('gpt-4o-mini');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use all config with titleEndpoint and verify provider switch', async () => {
|
||||||
|
// Mock the API key for the titleEndpoint provider
|
||||||
|
const originalApiKey = process.env.ANTHROPIC_API_KEY;
|
||||||
|
process.env.ANTHROPIC_API_KEY = 'test-anthropic-key';
|
||||||
|
|
||||||
|
// Remove endpoint-specific config to test 'all' config
|
||||||
|
delete mockReq.app.locals[EModelEndpoint.openAI];
|
||||||
|
|
||||||
|
// Set comprehensive 'all' config with all new title options
|
||||||
|
mockReq.app.locals.all = {
|
||||||
|
titleConvo: true,
|
||||||
|
titleModel: 'claude-3-haiku-20240307',
|
||||||
|
titleMethod: 'completion', // Testing the new default method
|
||||||
|
titlePrompt: 'Generate a concise, descriptive title for this conversation',
|
||||||
|
titlePromptTemplate: 'Conversation summary: {{content}}',
|
||||||
|
titleEndpoint: EModelEndpoint.anthropic, // Should switch provider to Anthropic
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = 'Test conversation about AI and machine learning';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
// Verify all config values were used
|
||||||
|
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: Providers.ANTHROPIC, // Critical: Verify provider switched to Anthropic
|
||||||
|
titleMethod: 'completion',
|
||||||
|
titlePrompt: 'Generate a concise, descriptive title for this conversation',
|
||||||
|
titlePromptTemplate: 'Conversation summary: {{content}}',
|
||||||
|
inputText: text,
|
||||||
|
contentParts: client.contentParts,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the model was set from 'all' config
|
||||||
|
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
|
||||||
|
expect(generateTitleCall.clientOptions.model).toBe('claude-3-haiku-20240307');
|
||||||
|
|
||||||
|
// Verify other client options are set correctly
|
||||||
|
expect(generateTitleCall.clientOptions).toMatchObject({
|
||||||
|
model: 'claude-3-haiku-20240307',
|
||||||
|
// Note: Anthropic's getOptions may set its own maxTokens value
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore the original API key
|
||||||
|
if (originalApiKey) {
|
||||||
|
process.env.ANTHROPIC_API_KEY = originalApiKey;
|
||||||
|
} else {
|
||||||
|
delete process.env.ANTHROPIC_API_KEY;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should test all titleMethod options from all config', async () => {
|
||||||
|
// Test each titleMethod: 'completion', 'functions', 'structured'
|
||||||
|
const titleMethods = ['completion', 'functions', 'structured'];
|
||||||
|
|
||||||
|
for (const method of titleMethods) {
|
||||||
|
// Clear previous calls
|
||||||
|
mockRun.generateTitle.mockClear();
|
||||||
|
|
||||||
|
// Remove endpoint config
|
||||||
|
delete mockReq.app.locals[EModelEndpoint.openAI];
|
||||||
|
|
||||||
|
// Set 'all' config with specific titleMethod
|
||||||
|
mockReq.app.locals.all = {
|
||||||
|
titleModel: 'gpt-4o-mini',
|
||||||
|
titleMethod: method,
|
||||||
|
titlePrompt: `Testing ${method} method`,
|
||||||
|
titlePromptTemplate: `Template for ${method}: {{content}}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = `Test conversation for ${method} method`;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
// Verify the correct titleMethod was used
|
||||||
|
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
titleMethod: method,
|
||||||
|
titlePrompt: `Testing ${method} method`,
|
||||||
|
titlePromptTemplate: `Template for ${method}: {{content}}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Azure-specific title generation', () => {
|
||||||
|
let originalEnv;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset mocks
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Save original environment variables
|
||||||
|
originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
// Mock Azure API keys
|
||||||
|
process.env.AZURE_OPENAI_API_KEY = 'test-azure-key';
|
||||||
|
process.env.AZURE_API_KEY = 'test-azure-key';
|
||||||
|
process.env.EASTUS_API_KEY = 'test-eastus-key';
|
||||||
|
process.env.EASTUS2_API_KEY = 'test-eastus2-key';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore environment variables
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use OPENAI provider for Azure serverless endpoints', async () => {
|
||||||
|
// Set up Azure endpoint with serverless config
|
||||||
|
mockAgent.endpoint = EModelEndpoint.azureOpenAI;
|
||||||
|
mockAgent.provider = EModelEndpoint.azureOpenAI;
|
||||||
|
mockReq.app.locals[EModelEndpoint.azureOpenAI] = {
|
||||||
|
titleConvo: true,
|
||||||
|
titleModel: 'grok-3',
|
||||||
|
titleMethod: 'completion',
|
||||||
|
titlePrompt: 'Azure serverless title prompt',
|
||||||
|
streamRate: 35,
|
||||||
|
modelGroupMap: {
|
||||||
|
'grok-3': {
|
||||||
|
group: 'Azure AI Foundry',
|
||||||
|
deploymentName: 'grok-3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
groupMap: {
|
||||||
|
'Azure AI Foundry': {
|
||||||
|
apiKey: '${AZURE_API_KEY}',
|
||||||
|
baseURL: 'https://test.services.ai.azure.com/models',
|
||||||
|
version: '2024-05-01-preview',
|
||||||
|
serverless: true,
|
||||||
|
models: {
|
||||||
|
'grok-3': {
|
||||||
|
deploymentName: 'grok-3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
|
||||||
|
mockReq.body.model = 'grok-3';
|
||||||
|
|
||||||
|
const text = 'Test Azure serverless conversation';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
// Verify provider was switched to OPENAI for serverless
|
||||||
|
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: Providers.OPENAI, // Should be OPENAI for serverless
|
||||||
|
titleMethod: 'completion',
|
||||||
|
titlePrompt: 'Azure serverless title prompt',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use AZURE provider for Azure endpoints with instanceName', async () => {
|
||||||
|
// Set up Azure endpoint
|
||||||
|
mockAgent.endpoint = EModelEndpoint.azureOpenAI;
|
||||||
|
mockAgent.provider = EModelEndpoint.azureOpenAI;
|
||||||
|
mockReq.app.locals[EModelEndpoint.azureOpenAI] = {
|
||||||
|
titleConvo: true,
|
||||||
|
titleModel: 'gpt-4o',
|
||||||
|
titleMethod: 'structured',
|
||||||
|
titlePrompt: 'Azure instance title prompt',
|
||||||
|
streamRate: 35,
|
||||||
|
modelGroupMap: {
|
||||||
|
'gpt-4o': {
|
||||||
|
group: 'eastus',
|
||||||
|
deploymentName: 'gpt-4o',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
groupMap: {
|
||||||
|
eastus: {
|
||||||
|
apiKey: '${EASTUS_API_KEY}',
|
||||||
|
instanceName: 'region-instance',
|
||||||
|
version: '2024-02-15-preview',
|
||||||
|
models: {
|
||||||
|
'gpt-4o': {
|
||||||
|
deploymentName: 'gpt-4o',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
|
||||||
|
mockReq.body.model = 'gpt-4o';
|
||||||
|
|
||||||
|
const text = 'Test Azure instance conversation';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
// Verify provider remains AZURE with instanceName
|
||||||
|
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: Providers.AZURE,
|
||||||
|
titleMethod: 'structured',
|
||||||
|
titlePrompt: 'Azure instance title prompt',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Azure titleModel with CURRENT_MODEL constant', async () => {
|
||||||
|
// Set up Azure endpoint
|
||||||
|
mockAgent.endpoint = EModelEndpoint.azureOpenAI;
|
||||||
|
mockAgent.provider = EModelEndpoint.azureOpenAI;
|
||||||
|
mockAgent.model_parameters.model = 'gpt-4o-latest';
|
||||||
|
mockReq.app.locals[EModelEndpoint.azureOpenAI] = {
|
||||||
|
titleConvo: true,
|
||||||
|
titleModel: Constants.CURRENT_MODEL,
|
||||||
|
titleMethod: 'functions',
|
||||||
|
streamRate: 35,
|
||||||
|
modelGroupMap: {
|
||||||
|
'gpt-4o-latest': {
|
||||||
|
group: 'region-eastus',
|
||||||
|
deploymentName: 'gpt-4o-mini',
|
||||||
|
version: '2024-02-15-preview',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
groupMap: {
|
||||||
|
'region-eastus': {
|
||||||
|
apiKey: '${EASTUS2_API_KEY}',
|
||||||
|
instanceName: 'test-instance',
|
||||||
|
version: '2024-12-01-preview',
|
||||||
|
models: {
|
||||||
|
'gpt-4o-latest': {
|
||||||
|
deploymentName: 'gpt-4o-mini',
|
||||||
|
version: '2024-02-15-preview',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
|
||||||
|
mockReq.body.model = 'gpt-4o-latest';
|
||||||
|
|
||||||
|
const text = 'Test Azure current model';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
// Verify it uses the correct model when titleModel is CURRENT_MODEL
|
||||||
|
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
|
||||||
|
// When CURRENT_MODEL is used with Azure, the model gets mapped to the deployment name
|
||||||
|
// In this case, 'gpt-4o-latest' is mapped to 'gpt-4o-mini' deployment
|
||||||
|
expect(generateTitleCall.clientOptions.model).toBe('gpt-4o-mini');
|
||||||
|
// Also verify that CURRENT_MODEL constant was not passed as the model
|
||||||
|
expect(generateTitleCall.clientOptions.model).not.toBe(Constants.CURRENT_MODEL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Azure with multiple model groups', async () => {
|
||||||
|
// Set up Azure endpoint
|
||||||
|
mockAgent.endpoint = EModelEndpoint.azureOpenAI;
|
||||||
|
mockAgent.provider = EModelEndpoint.azureOpenAI;
|
||||||
|
mockReq.app.locals[EModelEndpoint.azureOpenAI] = {
|
||||||
|
titleConvo: true,
|
||||||
|
titleModel: 'o1-mini',
|
||||||
|
titleMethod: 'completion',
|
||||||
|
streamRate: 35,
|
||||||
|
modelGroupMap: {
|
||||||
|
'gpt-4o': {
|
||||||
|
group: 'eastus',
|
||||||
|
deploymentName: 'gpt-4o',
|
||||||
|
},
|
||||||
|
'o1-mini': {
|
||||||
|
group: 'region-eastus',
|
||||||
|
deploymentName: 'o1-mini',
|
||||||
|
},
|
||||||
|
'codex-mini': {
|
||||||
|
group: 'codex-mini',
|
||||||
|
deploymentName: 'codex-mini',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
groupMap: {
|
||||||
|
eastus: {
|
||||||
|
apiKey: '${EASTUS_API_KEY}',
|
||||||
|
instanceName: 'region-eastus',
|
||||||
|
version: '2024-02-15-preview',
|
||||||
|
models: {
|
||||||
|
'gpt-4o': {
|
||||||
|
deploymentName: 'gpt-4o',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'region-eastus': {
|
||||||
|
apiKey: '${EASTUS2_API_KEY}',
|
||||||
|
instanceName: 'region-eastus2',
|
||||||
|
version: '2024-12-01-preview',
|
||||||
|
models: {
|
||||||
|
'o1-mini': {
|
||||||
|
deploymentName: 'o1-mini',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'codex-mini': {
|
||||||
|
apiKey: '${AZURE_API_KEY}',
|
||||||
|
baseURL: 'https://example.cognitiveservices.azure.com/openai/',
|
||||||
|
version: '2025-04-01-preview',
|
||||||
|
serverless: true,
|
||||||
|
models: {
|
||||||
|
'codex-mini': {
|
||||||
|
deploymentName: 'codex-mini',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
|
||||||
|
mockReq.body.model = 'o1-mini';
|
||||||
|
|
||||||
|
const text = 'Test Azure multi-group conversation';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
// Verify correct model and provider are used
|
||||||
|
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: Providers.AZURE,
|
||||||
|
titleMethod: 'completion',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
|
||||||
|
expect(generateTitleCall.clientOptions.model).toBe('o1-mini');
|
||||||
|
expect(generateTitleCall.clientOptions.maxTokens).toBeUndefined(); // o1 models shouldn't have maxTokens
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use all config as fallback for Azure endpoints', async () => {
|
||||||
|
// Set up Azure endpoint with minimal config
|
||||||
|
mockAgent.endpoint = EModelEndpoint.azureOpenAI;
|
||||||
|
mockAgent.provider = EModelEndpoint.azureOpenAI;
|
||||||
|
mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
|
||||||
|
mockReq.body.model = 'gpt-4';
|
||||||
|
|
||||||
|
// Remove Azure-specific config
|
||||||
|
delete mockReq.app.locals[EModelEndpoint.azureOpenAI];
|
||||||
|
|
||||||
|
// Set 'all' config as fallback with a serverless Azure config
|
||||||
|
mockReq.app.locals.all = {
|
||||||
|
titleConvo: true,
|
||||||
|
titleModel: 'gpt-4',
|
||||||
|
titleMethod: 'structured',
|
||||||
|
titlePrompt: 'Fallback title prompt from all config',
|
||||||
|
titlePromptTemplate: 'Template: {{content}}',
|
||||||
|
modelGroupMap: {
|
||||||
|
'gpt-4': {
|
||||||
|
group: 'default-group',
|
||||||
|
deploymentName: 'gpt-4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
groupMap: {
|
||||||
|
'default-group': {
|
||||||
|
apiKey: '${AZURE_API_KEY}',
|
||||||
|
baseURL: 'https://default.openai.azure.com/',
|
||||||
|
version: '2024-02-15-preview',
|
||||||
|
serverless: true,
|
||||||
|
models: {
|
||||||
|
'gpt-4': {
|
||||||
|
deploymentName: 'gpt-4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = 'Test Azure with all config fallback';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
// Verify all config is used
|
||||||
|
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: Providers.OPENAI, // Should be OPENAI when no instanceName
|
||||||
|
titleMethod: 'structured',
|
||||||
|
titlePrompt: 'Fallback title prompt from all config',
|
||||||
|
titlePromptTemplate: 'Template: {{content}}',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ const { saveMessage } = require('~/models');
|
|||||||
const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
||||||
let {
|
let {
|
||||||
text,
|
text,
|
||||||
|
isRegenerate,
|
||||||
endpointOption,
|
endpointOption,
|
||||||
conversationId,
|
conversationId,
|
||||||
isContinued = false,
|
isContinued = false,
|
||||||
@@ -167,6 +168,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
|||||||
onStart,
|
onStart,
|
||||||
getReqData,
|
getReqData,
|
||||||
isContinued,
|
isContinued,
|
||||||
|
isRegenerate,
|
||||||
editedContent,
|
editedContent,
|
||||||
conversationId,
|
conversationId,
|
||||||
parentMessageId,
|
parentMessageId,
|
||||||
|
|||||||
@@ -391,6 +391,22 @@ const uploadAgentAvatarHandler = async (req, res) => {
|
|||||||
return res.status(400).json({ message: 'Agent ID is required' });
|
return res.status(400).json({ message: 'Agent ID is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAdmin = req.user.role === SystemRoles.ADMIN;
|
||||||
|
const existingAgent = await getAgent({ id: agent_id });
|
||||||
|
|
||||||
|
if (!existingAgent) {
|
||||||
|
return res.status(404).json({ error: 'Agent not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthor = existingAgent.author.toString() === req.user.id;
|
||||||
|
const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor;
|
||||||
|
|
||||||
|
if (!hasEditPermission) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'You do not have permission to modify this non-collaborative agent',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const buffer = await fs.readFile(req.file.path);
|
const buffer = await fs.readFile(req.file.path);
|
||||||
|
|
||||||
const fileStrategy = req.app.locals.fileStrategy;
|
const fileStrategy = req.app.locals.fileStrategy;
|
||||||
@@ -413,14 +429,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
|
|||||||
source: fileStrategy,
|
source: fileStrategy,
|
||||||
};
|
};
|
||||||
|
|
||||||
let _avatar;
|
let _avatar = existingAgent.avatar;
|
||||||
try {
|
|
||||||
const agent = await getAgent({ id: agent_id });
|
|
||||||
_avatar = agent.avatar;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[/:agent_id/avatar] Error fetching agent', error);
|
|
||||||
_avatar = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_avatar && _avatar.source) {
|
if (_avatar && _avatar.source) {
|
||||||
const { deleteFile } = getStrategyFunctions(_avatar.source);
|
const { deleteFile } = getStrategyFunctions(_avatar.source);
|
||||||
@@ -442,7 +451,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
promises.push(
|
promises.push(
|
||||||
await updateAgent({ id: agent_id, author: req.user.id }, data, {
|
await updateAgent({ id: agent_id }, data, {
|
||||||
updatingUserId: req.user.id,
|
updatingUserId: req.user.id,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
const { nanoid } = require('nanoid');
|
const { nanoid } = require('nanoid');
|
||||||
const { EnvVar } = require('@librechat/agents');
|
const { EnvVar } = require('@librechat/agents');
|
||||||
const { checkAccess } = require('@librechat/api');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const { checkAccess, loadWebSearchAuth } = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
Tools,
|
Tools,
|
||||||
AuthType,
|
AuthType,
|
||||||
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');
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const { connectDb, indexSync } = require('~/db');
|
|||||||
const validateImageRequest = require('./middleware/validateImageRequest');
|
const validateImageRequest = require('./middleware/validateImageRequest');
|
||||||
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
|
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
|
||||||
const errorController = require('./controllers/ErrorController');
|
const errorController = require('./controllers/ErrorController');
|
||||||
const initializeMCP = require('./services/initializeMCP');
|
const initializeMCPs = require('./services/initializeMCPs');
|
||||||
const configureSocialLogins = require('./socialLogins');
|
const configureSocialLogins = require('./socialLogins');
|
||||||
const AppService = require('./services/AppService');
|
const AppService = require('./services/AppService');
|
||||||
const staticCache = require('./utils/staticCache');
|
const staticCache = require('./utils/staticCache');
|
||||||
@@ -146,7 +146,7 @@ const startServer = async () => {
|
|||||||
logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
|
logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeMCP(app);
|
initializeMCPs(app);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const { handleError } = require('@librechat/api');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const {
|
const {
|
||||||
EndpointURLs,
|
EndpointURLs,
|
||||||
@@ -14,7 +15,6 @@ const openAI = require('~/server/services/Endpoints/openAI');
|
|||||||
const agents = require('~/server/services/Endpoints/agents');
|
const agents = require('~/server/services/Endpoints/agents');
|
||||||
const custom = require('~/server/services/Endpoints/custom');
|
const custom = require('~/server/services/Endpoints/custom');
|
||||||
const google = require('~/server/services/Endpoints/google');
|
const google = require('~/server/services/Endpoints/google');
|
||||||
const { handleError } = require('~/server/utils');
|
|
||||||
|
|
||||||
const buildFunction = {
|
const buildFunction = {
|
||||||
[EModelEndpoint.openAI]: openAI.buildOptions,
|
[EModelEndpoint.openAI]: openAI.buildOptions,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { Time, CacheKeys } = require('librechat-data-provider');
|
const { Time, CacheKeys, ViolationTypes } = require('librechat-data-provider');
|
||||||
const clearPendingReq = require('~/cache/clearPendingReq');
|
const clearPendingReq = require('~/cache/clearPendingReq');
|
||||||
const { logViolation, getLogStores } = require('~/cache');
|
const { logViolation, getLogStores } = require('~/cache');
|
||||||
const { isEnabled } = require('~/server/utils');
|
const { isEnabled } = require('~/server/utils');
|
||||||
@@ -37,7 +37,7 @@ const concurrentLimiter = async (req, res, next) => {
|
|||||||
|
|
||||||
const userId = req.user?.id ?? req.user?._id ?? '';
|
const userId = req.user?.id ?? req.user?._id ?? '';
|
||||||
const limit = Math.max(CONCURRENT_MESSAGE_MAX, 1);
|
const limit = Math.max(CONCURRENT_MESSAGE_MAX, 1);
|
||||||
const type = 'concurrent';
|
const type = ViolationTypes.CONCURRENT;
|
||||||
|
|
||||||
const key = `${isEnabled(USE_REDIS) ? namespace : ''}:${userId}`;
|
const key = `${isEnabled(USE_REDIS) ? namespace : ''}:${userId}`;
|
||||||
const pendingRequests = +((await cache.get(key)) ?? 0);
|
const pendingRequests = +((await cache.get(key)) ?? 0);
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { isEnabled } = require('@librechat/api');
|
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
|
||||||
const { ViolationTypes } = require('librechat-data-provider');
|
const { ViolationTypes } = require('librechat-data-provider');
|
||||||
const ioredisClient = require('~/cache/ioredisClient');
|
const { limiterCache } = require('~/cache/cacheFactory');
|
||||||
const logViolation = require('~/cache/logViolation');
|
const logViolation = require('~/cache/logViolation');
|
||||||
|
|
||||||
const getEnvironmentVariables = () => {
|
const getEnvironmentVariables = () => {
|
||||||
@@ -11,6 +8,7 @@ const getEnvironmentVariables = () => {
|
|||||||
const FORK_IP_WINDOW = parseInt(process.env.FORK_IP_WINDOW) || 1;
|
const FORK_IP_WINDOW = parseInt(process.env.FORK_IP_WINDOW) || 1;
|
||||||
const FORK_USER_MAX = parseInt(process.env.FORK_USER_MAX) || 7;
|
const FORK_USER_MAX = parseInt(process.env.FORK_USER_MAX) || 7;
|
||||||
const FORK_USER_WINDOW = parseInt(process.env.FORK_USER_WINDOW) || 1;
|
const FORK_USER_WINDOW = parseInt(process.env.FORK_USER_WINDOW) || 1;
|
||||||
|
const FORK_VIOLATION_SCORE = process.env.FORK_VIOLATION_SCORE;
|
||||||
|
|
||||||
const forkIpWindowMs = FORK_IP_WINDOW * 60 * 1000;
|
const forkIpWindowMs = FORK_IP_WINDOW * 60 * 1000;
|
||||||
const forkIpMax = FORK_IP_MAX;
|
const forkIpMax = FORK_IP_MAX;
|
||||||
@@ -27,12 +25,18 @@ const getEnvironmentVariables = () => {
|
|||||||
forkUserWindowMs,
|
forkUserWindowMs,
|
||||||
forkUserMax,
|
forkUserMax,
|
||||||
forkUserWindowInMinutes,
|
forkUserWindowInMinutes,
|
||||||
|
forkViolationScore: FORK_VIOLATION_SCORE,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const createForkHandler = (ip = true) => {
|
const createForkHandler = (ip = true) => {
|
||||||
const { forkIpMax, forkIpWindowInMinutes, forkUserMax, forkUserWindowInMinutes } =
|
const {
|
||||||
getEnvironmentVariables();
|
forkIpMax,
|
||||||
|
forkUserMax,
|
||||||
|
forkViolationScore,
|
||||||
|
forkIpWindowInMinutes,
|
||||||
|
forkUserWindowInMinutes,
|
||||||
|
} = getEnvironmentVariables();
|
||||||
|
|
||||||
return async (req, res) => {
|
return async (req, res) => {
|
||||||
const type = ViolationTypes.FILE_UPLOAD_LIMIT;
|
const type = ViolationTypes.FILE_UPLOAD_LIMIT;
|
||||||
@@ -43,7 +47,7 @@ const createForkHandler = (ip = true) => {
|
|||||||
windowInMinutes: ip ? forkIpWindowInMinutes : forkUserWindowInMinutes,
|
windowInMinutes: ip ? forkIpWindowInMinutes : forkUserWindowInMinutes,
|
||||||
};
|
};
|
||||||
|
|
||||||
await logViolation(req, res, type, errorMessage);
|
await logViolation(req, res, type, errorMessage, forkViolationScore);
|
||||||
res.status(429).json({ message: 'Too many conversation fork requests. Try again later' });
|
res.status(429).json({ message: 'Too many conversation fork requests. Try again later' });
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -55,6 +59,7 @@ const createForkLimiters = () => {
|
|||||||
windowMs: forkIpWindowMs,
|
windowMs: forkIpWindowMs,
|
||||||
max: forkIpMax,
|
max: forkIpMax,
|
||||||
handler: createForkHandler(),
|
handler: createForkHandler(),
|
||||||
|
store: limiterCache('fork_ip_limiter'),
|
||||||
};
|
};
|
||||||
const userLimiterOptions = {
|
const userLimiterOptions = {
|
||||||
windowMs: forkUserWindowMs,
|
windowMs: forkUserWindowMs,
|
||||||
@@ -63,23 +68,9 @@ const createForkLimiters = () => {
|
|||||||
keyGenerator: function (req) {
|
keyGenerator: function (req) {
|
||||||
return req.user?.id;
|
return req.user?.id;
|
||||||
},
|
},
|
||||||
|
store: limiterCache('fork_user_limiter'),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
|
||||||
logger.debug('Using Redis for fork rate limiters.');
|
|
||||||
const sendCommand = (...args) => ioredisClient.call(...args);
|
|
||||||
const ipStore = new RedisStore({
|
|
||||||
sendCommand,
|
|
||||||
prefix: 'fork_ip_limiter:',
|
|
||||||
});
|
|
||||||
const userStore = new RedisStore({
|
|
||||||
sendCommand,
|
|
||||||
prefix: 'fork_user_limiter:',
|
|
||||||
});
|
|
||||||
ipLimiterOptions.store = ipStore;
|
|
||||||
userLimiterOptions.store = userStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
const forkIpLimiter = rateLimit(ipLimiterOptions);
|
const forkIpLimiter = rateLimit(ipLimiterOptions);
|
||||||
const forkUserLimiter = rateLimit(userLimiterOptions);
|
const forkUserLimiter = rateLimit(userLimiterOptions);
|
||||||
return { forkIpLimiter, forkUserLimiter };
|
return { forkIpLimiter, forkUserLimiter };
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { isEnabled } = require('@librechat/api');
|
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
|
||||||
const { ViolationTypes } = require('librechat-data-provider');
|
const { ViolationTypes } = require('librechat-data-provider');
|
||||||
const ioredisClient = require('~/cache/ioredisClient');
|
const { limiterCache } = require('~/cache/cacheFactory');
|
||||||
const logViolation = require('~/cache/logViolation');
|
const logViolation = require('~/cache/logViolation');
|
||||||
|
|
||||||
const getEnvironmentVariables = () => {
|
const getEnvironmentVariables = () => {
|
||||||
@@ -11,6 +8,7 @@ const getEnvironmentVariables = () => {
|
|||||||
const IMPORT_IP_WINDOW = parseInt(process.env.IMPORT_IP_WINDOW) || 15;
|
const IMPORT_IP_WINDOW = parseInt(process.env.IMPORT_IP_WINDOW) || 15;
|
||||||
const IMPORT_USER_MAX = parseInt(process.env.IMPORT_USER_MAX) || 50;
|
const IMPORT_USER_MAX = parseInt(process.env.IMPORT_USER_MAX) || 50;
|
||||||
const IMPORT_USER_WINDOW = parseInt(process.env.IMPORT_USER_WINDOW) || 15;
|
const IMPORT_USER_WINDOW = parseInt(process.env.IMPORT_USER_WINDOW) || 15;
|
||||||
|
const IMPORT_VIOLATION_SCORE = process.env.IMPORT_VIOLATION_SCORE;
|
||||||
|
|
||||||
const importIpWindowMs = IMPORT_IP_WINDOW * 60 * 1000;
|
const importIpWindowMs = IMPORT_IP_WINDOW * 60 * 1000;
|
||||||
const importIpMax = IMPORT_IP_MAX;
|
const importIpMax = IMPORT_IP_MAX;
|
||||||
@@ -27,12 +25,18 @@ const getEnvironmentVariables = () => {
|
|||||||
importUserWindowMs,
|
importUserWindowMs,
|
||||||
importUserMax,
|
importUserMax,
|
||||||
importUserWindowInMinutes,
|
importUserWindowInMinutes,
|
||||||
|
importViolationScore: IMPORT_VIOLATION_SCORE,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const createImportHandler = (ip = true) => {
|
const createImportHandler = (ip = true) => {
|
||||||
const { importIpMax, importIpWindowInMinutes, importUserMax, importUserWindowInMinutes } =
|
const {
|
||||||
getEnvironmentVariables();
|
importIpMax,
|
||||||
|
importUserMax,
|
||||||
|
importViolationScore,
|
||||||
|
importIpWindowInMinutes,
|
||||||
|
importUserWindowInMinutes,
|
||||||
|
} = getEnvironmentVariables();
|
||||||
|
|
||||||
return async (req, res) => {
|
return async (req, res) => {
|
||||||
const type = ViolationTypes.FILE_UPLOAD_LIMIT;
|
const type = ViolationTypes.FILE_UPLOAD_LIMIT;
|
||||||
@@ -43,7 +47,7 @@ const createImportHandler = (ip = true) => {
|
|||||||
windowInMinutes: ip ? importIpWindowInMinutes : importUserWindowInMinutes,
|
windowInMinutes: ip ? importIpWindowInMinutes : importUserWindowInMinutes,
|
||||||
};
|
};
|
||||||
|
|
||||||
await logViolation(req, res, type, errorMessage);
|
await logViolation(req, res, type, errorMessage, importViolationScore);
|
||||||
res.status(429).json({ message: 'Too many conversation import requests. Try again later' });
|
res.status(429).json({ message: 'Too many conversation import requests. Try again later' });
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -56,6 +60,7 @@ const createImportLimiters = () => {
|
|||||||
windowMs: importIpWindowMs,
|
windowMs: importIpWindowMs,
|
||||||
max: importIpMax,
|
max: importIpMax,
|
||||||
handler: createImportHandler(),
|
handler: createImportHandler(),
|
||||||
|
store: limiterCache('import_ip_limiter'),
|
||||||
};
|
};
|
||||||
const userLimiterOptions = {
|
const userLimiterOptions = {
|
||||||
windowMs: importUserWindowMs,
|
windowMs: importUserWindowMs,
|
||||||
@@ -64,23 +69,9 @@ const createImportLimiters = () => {
|
|||||||
keyGenerator: function (req) {
|
keyGenerator: function (req) {
|
||||||
return req.user?.id; // Use the user ID or NULL if not available
|
return req.user?.id; // Use the user ID or NULL if not available
|
||||||
},
|
},
|
||||||
|
store: limiterCache('import_user_limiter'),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
|
||||||
logger.debug('Using Redis for import rate limiters.');
|
|
||||||
const sendCommand = (...args) => ioredisClient.call(...args);
|
|
||||||
const ipStore = new RedisStore({
|
|
||||||
sendCommand,
|
|
||||||
prefix: 'import_ip_limiter:',
|
|
||||||
});
|
|
||||||
const userStore = new RedisStore({
|
|
||||||
sendCommand,
|
|
||||||
prefix: 'import_user_limiter:',
|
|
||||||
});
|
|
||||||
ipLimiterOptions.store = ipStore;
|
|
||||||
userLimiterOptions.store = userStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
const importIpLimiter = rateLimit(ipLimiterOptions);
|
const importIpLimiter = rateLimit(ipLimiterOptions);
|
||||||
const importUserLimiter = rateLimit(userLimiterOptions);
|
const importUserLimiter = rateLimit(userLimiterOptions);
|
||||||
return { importIpLimiter, importUserLimiter };
|
return { importIpLimiter, importUserLimiter };
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
const { ViolationTypes } = require('librechat-data-provider');
|
||||||
const { removePorts, isEnabled } = require('~/server/utils');
|
const { removePorts } = require('~/server/utils');
|
||||||
const ioredisClient = require('~/cache/ioredisClient');
|
const { limiterCache } = require('~/cache/cacheFactory');
|
||||||
const { logViolation } = require('~/cache');
|
const { logViolation } = require('~/cache');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const { LOGIN_WINDOW = 5, LOGIN_MAX = 7, LOGIN_VIOLATION_SCORE: score } = process.env;
|
const { LOGIN_WINDOW = 5, LOGIN_MAX = 7, LOGIN_VIOLATION_SCORE: score } = process.env;
|
||||||
const windowMs = LOGIN_WINDOW * 60 * 1000;
|
const windowMs = LOGIN_WINDOW * 60 * 1000;
|
||||||
@@ -12,7 +11,7 @@ const windowInMinutes = windowMs / 60000;
|
|||||||
const message = `Too many login attempts, please try again after ${windowInMinutes} minutes.`;
|
const message = `Too many login attempts, please try again after ${windowInMinutes} minutes.`;
|
||||||
|
|
||||||
const handler = async (req, res) => {
|
const handler = async (req, res) => {
|
||||||
const type = 'logins';
|
const type = ViolationTypes.LOGINS;
|
||||||
const errorMessage = {
|
const errorMessage = {
|
||||||
type,
|
type,
|
||||||
max,
|
max,
|
||||||
@@ -28,17 +27,9 @@ const limiterOptions = {
|
|||||||
max,
|
max,
|
||||||
handler,
|
handler,
|
||||||
keyGenerator: removePorts,
|
keyGenerator: removePorts,
|
||||||
|
store: limiterCache('login_limiter'),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
|
||||||
logger.debug('Using Redis for login rate limiter.');
|
|
||||||
const store = new RedisStore({
|
|
||||||
sendCommand: (...args) => ioredisClient.call(...args),
|
|
||||||
prefix: 'login_limiter:',
|
|
||||||
});
|
|
||||||
limiterOptions.store = store;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loginLimiter = rateLimit(limiterOptions);
|
const loginLimiter = rateLimit(limiterOptions);
|
||||||
|
|
||||||
module.exports = loginLimiter;
|
module.exports = loginLimiter;
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
const { ViolationTypes } = require('librechat-data-provider');
|
||||||
const denyRequest = require('~/server/middleware/denyRequest');
|
const denyRequest = require('~/server/middleware/denyRequest');
|
||||||
const ioredisClient = require('~/cache/ioredisClient');
|
const { limiterCache } = require('~/cache/cacheFactory');
|
||||||
const { isEnabled } = require('~/server/utils');
|
|
||||||
const { logViolation } = require('~/cache');
|
const { logViolation } = require('~/cache');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
MESSAGE_IP_MAX = 40,
|
MESSAGE_IP_MAX = 40,
|
||||||
MESSAGE_IP_WINDOW = 1,
|
MESSAGE_IP_WINDOW = 1,
|
||||||
MESSAGE_USER_MAX = 40,
|
MESSAGE_USER_MAX = 40,
|
||||||
MESSAGE_USER_WINDOW = 1,
|
MESSAGE_USER_WINDOW = 1,
|
||||||
|
MESSAGE_VIOLATION_SCORE: score,
|
||||||
} = process.env;
|
} = process.env;
|
||||||
|
|
||||||
const ipWindowMs = MESSAGE_IP_WINDOW * 60 * 1000;
|
const ipWindowMs = MESSAGE_IP_WINDOW * 60 * 1000;
|
||||||
@@ -31,7 +30,7 @@ const userWindowInMinutes = userWindowMs / 60000;
|
|||||||
*/
|
*/
|
||||||
const createHandler = (ip = true) => {
|
const createHandler = (ip = true) => {
|
||||||
return async (req, res) => {
|
return async (req, res) => {
|
||||||
const type = 'message_limit';
|
const type = ViolationTypes.MESSAGE_LIMIT;
|
||||||
const errorMessage = {
|
const errorMessage = {
|
||||||
type,
|
type,
|
||||||
max: ip ? ipMax : userMax,
|
max: ip ? ipMax : userMax,
|
||||||
@@ -39,7 +38,7 @@ const createHandler = (ip = true) => {
|
|||||||
windowInMinutes: ip ? ipWindowInMinutes : userWindowInMinutes,
|
windowInMinutes: ip ? ipWindowInMinutes : userWindowInMinutes,
|
||||||
};
|
};
|
||||||
|
|
||||||
await logViolation(req, res, type, errorMessage);
|
await logViolation(req, res, type, errorMessage, score);
|
||||||
return await denyRequest(req, res, errorMessage);
|
return await denyRequest(req, res, errorMessage);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -51,6 +50,7 @@ const ipLimiterOptions = {
|
|||||||
windowMs: ipWindowMs,
|
windowMs: ipWindowMs,
|
||||||
max: ipMax,
|
max: ipMax,
|
||||||
handler: createHandler(),
|
handler: createHandler(),
|
||||||
|
store: limiterCache('message_ip_limiter'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const userLimiterOptions = {
|
const userLimiterOptions = {
|
||||||
@@ -60,23 +60,9 @@ const userLimiterOptions = {
|
|||||||
keyGenerator: function (req) {
|
keyGenerator: function (req) {
|
||||||
return req.user?.id; // Use the user ID or NULL if not available
|
return req.user?.id; // Use the user ID or NULL if not available
|
||||||
},
|
},
|
||||||
|
store: limiterCache('message_user_limiter'),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
|
||||||
logger.debug('Using Redis for message rate limiters.');
|
|
||||||
const sendCommand = (...args) => ioredisClient.call(...args);
|
|
||||||
const ipStore = new RedisStore({
|
|
||||||
sendCommand,
|
|
||||||
prefix: 'message_ip_limiter:',
|
|
||||||
});
|
|
||||||
const userStore = new RedisStore({
|
|
||||||
sendCommand,
|
|
||||||
prefix: 'message_user_limiter:',
|
|
||||||
});
|
|
||||||
ipLimiterOptions.store = ipStore;
|
|
||||||
userLimiterOptions.store = userStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Message request rate limiter by IP
|
* Message request rate limiter by IP
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
const { ViolationTypes } = require('librechat-data-provider');
|
||||||
const { removePorts, isEnabled } = require('~/server/utils');
|
const { removePorts } = require('~/server/utils');
|
||||||
const ioredisClient = require('~/cache/ioredisClient');
|
const { limiterCache } = require('~/cache/cacheFactory');
|
||||||
const { logViolation } = require('~/cache');
|
const { logViolation } = require('~/cache');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const { REGISTER_WINDOW = 60, REGISTER_MAX = 5, REGISTRATION_VIOLATION_SCORE: score } = process.env;
|
const { REGISTER_WINDOW = 60, REGISTER_MAX = 5, REGISTRATION_VIOLATION_SCORE: score } = process.env;
|
||||||
const windowMs = REGISTER_WINDOW * 60 * 1000;
|
const windowMs = REGISTER_WINDOW * 60 * 1000;
|
||||||
@@ -12,7 +11,7 @@ const windowInMinutes = windowMs / 60000;
|
|||||||
const message = `Too many accounts created, please try again after ${windowInMinutes} minutes`;
|
const message = `Too many accounts created, please try again after ${windowInMinutes} minutes`;
|
||||||
|
|
||||||
const handler = async (req, res) => {
|
const handler = async (req, res) => {
|
||||||
const type = 'registrations';
|
const type = ViolationTypes.REGISTRATIONS;
|
||||||
const errorMessage = {
|
const errorMessage = {
|
||||||
type,
|
type,
|
||||||
max,
|
max,
|
||||||
@@ -28,17 +27,9 @@ const limiterOptions = {
|
|||||||
max,
|
max,
|
||||||
handler,
|
handler,
|
||||||
keyGenerator: removePorts,
|
keyGenerator: removePorts,
|
||||||
|
store: limiterCache('register_limiter'),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
|
||||||
logger.debug('Using Redis for register rate limiter.');
|
|
||||||
const store = new RedisStore({
|
|
||||||
sendCommand: (...args) => ioredisClient.call(...args),
|
|
||||||
prefix: 'register_limiter:',
|
|
||||||
});
|
|
||||||
limiterOptions.store = store;
|
|
||||||
}
|
|
||||||
|
|
||||||
const registerLimiter = rateLimit(limiterOptions);
|
const registerLimiter = rateLimit(limiterOptions);
|
||||||
|
|
||||||
module.exports = registerLimiter;
|
module.exports = registerLimiter;
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
|
||||||
const { ViolationTypes } = require('librechat-data-provider');
|
const { ViolationTypes } = require('librechat-data-provider');
|
||||||
const { removePorts, isEnabled } = require('~/server/utils');
|
const { removePorts } = require('~/server/utils');
|
||||||
const ioredisClient = require('~/cache/ioredisClient');
|
const { limiterCache } = require('~/cache/cacheFactory');
|
||||||
const { logViolation } = require('~/cache');
|
const { logViolation } = require('~/cache');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
RESET_PASSWORD_WINDOW = 2,
|
RESET_PASSWORD_WINDOW = 2,
|
||||||
@@ -33,17 +31,9 @@ const limiterOptions = {
|
|||||||
max,
|
max,
|
||||||
handler,
|
handler,
|
||||||
keyGenerator: removePorts,
|
keyGenerator: removePorts,
|
||||||
|
store: limiterCache('reset_password_limiter'),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
|
||||||
logger.debug('Using Redis for reset password rate limiter.');
|
|
||||||
const store = new RedisStore({
|
|
||||||
sendCommand: (...args) => ioredisClient.call(...args),
|
|
||||||
prefix: 'reset_password_limiter:',
|
|
||||||
});
|
|
||||||
limiterOptions.store = store;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetPasswordLimiter = rateLimit(limiterOptions);
|
const resetPasswordLimiter = rateLimit(limiterOptions);
|
||||||
|
|
||||||
module.exports = resetPasswordLimiter;
|
module.exports = resetPasswordLimiter;
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
|
||||||
const { ViolationTypes } = require('librechat-data-provider');
|
const { ViolationTypes } = require('librechat-data-provider');
|
||||||
const ioredisClient = require('~/cache/ioredisClient');
|
const { limiterCache } = require('~/cache/cacheFactory');
|
||||||
const logViolation = require('~/cache/logViolation');
|
const logViolation = require('~/cache/logViolation');
|
||||||
const { isEnabled } = require('~/server/utils');
|
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const getEnvironmentVariables = () => {
|
const getEnvironmentVariables = () => {
|
||||||
const STT_IP_MAX = parseInt(process.env.STT_IP_MAX) || 100;
|
const STT_IP_MAX = parseInt(process.env.STT_IP_MAX) || 100;
|
||||||
const STT_IP_WINDOW = parseInt(process.env.STT_IP_WINDOW) || 1;
|
const STT_IP_WINDOW = parseInt(process.env.STT_IP_WINDOW) || 1;
|
||||||
const STT_USER_MAX = parseInt(process.env.STT_USER_MAX) || 50;
|
const STT_USER_MAX = parseInt(process.env.STT_USER_MAX) || 50;
|
||||||
const STT_USER_WINDOW = parseInt(process.env.STT_USER_WINDOW) || 1;
|
const STT_USER_WINDOW = parseInt(process.env.STT_USER_WINDOW) || 1;
|
||||||
|
const STT_VIOLATION_SCORE = process.env.STT_VIOLATION_SCORE;
|
||||||
|
|
||||||
const sttIpWindowMs = STT_IP_WINDOW * 60 * 1000;
|
const sttIpWindowMs = STT_IP_WINDOW * 60 * 1000;
|
||||||
const sttIpMax = STT_IP_MAX;
|
const sttIpMax = STT_IP_MAX;
|
||||||
@@ -27,11 +25,12 @@ const getEnvironmentVariables = () => {
|
|||||||
sttUserWindowMs,
|
sttUserWindowMs,
|
||||||
sttUserMax,
|
sttUserMax,
|
||||||
sttUserWindowInMinutes,
|
sttUserWindowInMinutes,
|
||||||
|
sttViolationScore: STT_VIOLATION_SCORE,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const createSTTHandler = (ip = true) => {
|
const createSTTHandler = (ip = true) => {
|
||||||
const { sttIpMax, sttIpWindowInMinutes, sttUserMax, sttUserWindowInMinutes } =
|
const { sttIpMax, sttIpWindowInMinutes, sttUserMax, sttUserWindowInMinutes, sttViolationScore } =
|
||||||
getEnvironmentVariables();
|
getEnvironmentVariables();
|
||||||
|
|
||||||
return async (req, res) => {
|
return async (req, res) => {
|
||||||
@@ -43,7 +42,7 @@ const createSTTHandler = (ip = true) => {
|
|||||||
windowInMinutes: ip ? sttIpWindowInMinutes : sttUserWindowInMinutes,
|
windowInMinutes: ip ? sttIpWindowInMinutes : sttUserWindowInMinutes,
|
||||||
};
|
};
|
||||||
|
|
||||||
await logViolation(req, res, type, errorMessage);
|
await logViolation(req, res, type, errorMessage, sttViolationScore);
|
||||||
res.status(429).json({ message: 'Too many STT requests. Try again later' });
|
res.status(429).json({ message: 'Too many STT requests. Try again later' });
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -55,6 +54,7 @@ const createSTTLimiters = () => {
|
|||||||
windowMs: sttIpWindowMs,
|
windowMs: sttIpWindowMs,
|
||||||
max: sttIpMax,
|
max: sttIpMax,
|
||||||
handler: createSTTHandler(),
|
handler: createSTTHandler(),
|
||||||
|
store: limiterCache('stt_ip_limiter'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const userLimiterOptions = {
|
const userLimiterOptions = {
|
||||||
@@ -64,23 +64,9 @@ const createSTTLimiters = () => {
|
|||||||
keyGenerator: function (req) {
|
keyGenerator: function (req) {
|
||||||
return req.user?.id; // Use the user ID or NULL if not available
|
return req.user?.id; // Use the user ID or NULL if not available
|
||||||
},
|
},
|
||||||
|
store: limiterCache('stt_user_limiter'),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
|
||||||
logger.debug('Using Redis for STT rate limiters.');
|
|
||||||
const sendCommand = (...args) => ioredisClient.call(...args);
|
|
||||||
const ipStore = new RedisStore({
|
|
||||||
sendCommand,
|
|
||||||
prefix: 'stt_ip_limiter:',
|
|
||||||
});
|
|
||||||
const userStore = new RedisStore({
|
|
||||||
sendCommand,
|
|
||||||
prefix: 'stt_user_limiter:',
|
|
||||||
});
|
|
||||||
ipLimiterOptions.store = ipStore;
|
|
||||||
userLimiterOptions.store = userStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sttIpLimiter = rateLimit(ipLimiterOptions);
|
const sttIpLimiter = rateLimit(ipLimiterOptions);
|
||||||
const sttUserLimiter = rateLimit(userLimiterOptions);
|
const sttUserLimiter = rateLimit(userLimiterOptions);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
|
||||||
const { ViolationTypes } = require('librechat-data-provider');
|
const { ViolationTypes } = require('librechat-data-provider');
|
||||||
const ioredisClient = require('~/cache/ioredisClient');
|
const { limiterCache } = require('~/cache/cacheFactory');
|
||||||
const logViolation = require('~/cache/logViolation');
|
const logViolation = require('~/cache/logViolation');
|
||||||
const { isEnabled } = require('~/server/utils');
|
|
||||||
const { logger } = require('~/config');
|
const { TOOL_CALL_VIOLATION_SCORE: score } = process.env;
|
||||||
|
|
||||||
const handler = async (req, res) => {
|
const handler = async (req, res) => {
|
||||||
const type = ViolationTypes.TOOL_CALL_LIMIT;
|
const type = ViolationTypes.TOOL_CALL_LIMIT;
|
||||||
@@ -15,7 +14,7 @@ const handler = async (req, res) => {
|
|||||||
windowInMinutes: 1,
|
windowInMinutes: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
await logViolation(req, res, type, errorMessage, 0);
|
await logViolation(req, res, type, errorMessage, score);
|
||||||
res.status(429).json({ message: 'Too many tool call requests. Try again later' });
|
res.status(429).json({ message: 'Too many tool call requests. Try again later' });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,17 +25,9 @@ const limiterOptions = {
|
|||||||
keyGenerator: function (req) {
|
keyGenerator: function (req) {
|
||||||
return req.user?.id;
|
return req.user?.id;
|
||||||
},
|
},
|
||||||
|
store: limiterCache('tool_call_limiter'),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
|
||||||
logger.debug('Using Redis for tool call rate limiter.');
|
|
||||||
const store = new RedisStore({
|
|
||||||
sendCommand: (...args) => ioredisClient.call(...args),
|
|
||||||
prefix: 'tool_call_limiter:',
|
|
||||||
});
|
|
||||||
limiterOptions.store = store;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolCallLimiter = rateLimit(limiterOptions);
|
const toolCallLimiter = rateLimit(limiterOptions);
|
||||||
|
|
||||||
module.exports = toolCallLimiter;
|
module.exports = toolCallLimiter;
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
|
||||||
const { ViolationTypes } = require('librechat-data-provider');
|
const { ViolationTypes } = require('librechat-data-provider');
|
||||||
const ioredisClient = require('~/cache/ioredisClient');
|
|
||||||
const logViolation = require('~/cache/logViolation');
|
const logViolation = require('~/cache/logViolation');
|
||||||
const { isEnabled } = require('~/server/utils');
|
const { limiterCache } = require('~/cache/cacheFactory');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const getEnvironmentVariables = () => {
|
const getEnvironmentVariables = () => {
|
||||||
const TTS_IP_MAX = parseInt(process.env.TTS_IP_MAX) || 100;
|
const TTS_IP_MAX = parseInt(process.env.TTS_IP_MAX) || 100;
|
||||||
const TTS_IP_WINDOW = parseInt(process.env.TTS_IP_WINDOW) || 1;
|
const TTS_IP_WINDOW = parseInt(process.env.TTS_IP_WINDOW) || 1;
|
||||||
const TTS_USER_MAX = parseInt(process.env.TTS_USER_MAX) || 50;
|
const TTS_USER_MAX = parseInt(process.env.TTS_USER_MAX) || 50;
|
||||||
const TTS_USER_WINDOW = parseInt(process.env.TTS_USER_WINDOW) || 1;
|
const TTS_USER_WINDOW = parseInt(process.env.TTS_USER_WINDOW) || 1;
|
||||||
|
const TTS_VIOLATION_SCORE = process.env.TTS_VIOLATION_SCORE;
|
||||||
|
|
||||||
const ttsIpWindowMs = TTS_IP_WINDOW * 60 * 1000;
|
const ttsIpWindowMs = TTS_IP_WINDOW * 60 * 1000;
|
||||||
const ttsIpMax = TTS_IP_MAX;
|
const ttsIpMax = TTS_IP_MAX;
|
||||||
@@ -27,11 +25,12 @@ const getEnvironmentVariables = () => {
|
|||||||
ttsUserWindowMs,
|
ttsUserWindowMs,
|
||||||
ttsUserMax,
|
ttsUserMax,
|
||||||
ttsUserWindowInMinutes,
|
ttsUserWindowInMinutes,
|
||||||
|
ttsViolationScore: TTS_VIOLATION_SCORE,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const createTTSHandler = (ip = true) => {
|
const createTTSHandler = (ip = true) => {
|
||||||
const { ttsIpMax, ttsIpWindowInMinutes, ttsUserMax, ttsUserWindowInMinutes } =
|
const { ttsIpMax, ttsIpWindowInMinutes, ttsUserMax, ttsUserWindowInMinutes, ttsViolationScore } =
|
||||||
getEnvironmentVariables();
|
getEnvironmentVariables();
|
||||||
|
|
||||||
return async (req, res) => {
|
return async (req, res) => {
|
||||||
@@ -43,7 +42,7 @@ const createTTSHandler = (ip = true) => {
|
|||||||
windowInMinutes: ip ? ttsIpWindowInMinutes : ttsUserWindowInMinutes,
|
windowInMinutes: ip ? ttsIpWindowInMinutes : ttsUserWindowInMinutes,
|
||||||
};
|
};
|
||||||
|
|
||||||
await logViolation(req, res, type, errorMessage);
|
await logViolation(req, res, type, errorMessage, ttsViolationScore);
|
||||||
res.status(429).json({ message: 'Too many TTS requests. Try again later' });
|
res.status(429).json({ message: 'Too many TTS requests. Try again later' });
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -55,32 +54,19 @@ const createTTSLimiters = () => {
|
|||||||
windowMs: ttsIpWindowMs,
|
windowMs: ttsIpWindowMs,
|
||||||
max: ttsIpMax,
|
max: ttsIpMax,
|
||||||
handler: createTTSHandler(),
|
handler: createTTSHandler(),
|
||||||
|
store: limiterCache('tts_ip_limiter'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const userLimiterOptions = {
|
const userLimiterOptions = {
|
||||||
windowMs: ttsUserWindowMs,
|
windowMs: ttsUserWindowMs,
|
||||||
max: ttsUserMax,
|
max: ttsUserMax,
|
||||||
handler: createTTSHandler(false),
|
handler: createTTSHandler(false),
|
||||||
|
store: limiterCache('tts_user_limiter'),
|
||||||
keyGenerator: function (req) {
|
keyGenerator: function (req) {
|
||||||
return req.user?.id; // Use the user ID or NULL if not available
|
return req.user?.id; // Use the user ID or NULL if not available
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
|
||||||
logger.debug('Using Redis for TTS rate limiters.');
|
|
||||||
const sendCommand = (...args) => ioredisClient.call(...args);
|
|
||||||
const ipStore = new RedisStore({
|
|
||||||
sendCommand,
|
|
||||||
prefix: 'tts_ip_limiter:',
|
|
||||||
});
|
|
||||||
const userStore = new RedisStore({
|
|
||||||
sendCommand,
|
|
||||||
prefix: 'tts_user_limiter:',
|
|
||||||
});
|
|
||||||
ipLimiterOptions.store = ipStore;
|
|
||||||
userLimiterOptions.store = userStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ttsIpLimiter = rateLimit(ipLimiterOptions);
|
const ttsIpLimiter = rateLimit(ipLimiterOptions);
|
||||||
const ttsUserLimiter = rateLimit(userLimiterOptions);
|
const ttsUserLimiter = rateLimit(userLimiterOptions);
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
|
||||||
const { ViolationTypes } = require('librechat-data-provider');
|
const { ViolationTypes } = require('librechat-data-provider');
|
||||||
const ioredisClient = require('~/cache/ioredisClient');
|
const { limiterCache } = require('~/cache/cacheFactory');
|
||||||
const logViolation = require('~/cache/logViolation');
|
const logViolation = require('~/cache/logViolation');
|
||||||
const { isEnabled } = require('~/server/utils');
|
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const getEnvironmentVariables = () => {
|
const getEnvironmentVariables = () => {
|
||||||
const FILE_UPLOAD_IP_MAX = parseInt(process.env.FILE_UPLOAD_IP_MAX) || 100;
|
const FILE_UPLOAD_IP_MAX = parseInt(process.env.FILE_UPLOAD_IP_MAX) || 100;
|
||||||
const FILE_UPLOAD_IP_WINDOW = parseInt(process.env.FILE_UPLOAD_IP_WINDOW) || 15;
|
const FILE_UPLOAD_IP_WINDOW = parseInt(process.env.FILE_UPLOAD_IP_WINDOW) || 15;
|
||||||
const FILE_UPLOAD_USER_MAX = parseInt(process.env.FILE_UPLOAD_USER_MAX) || 50;
|
const FILE_UPLOAD_USER_MAX = parseInt(process.env.FILE_UPLOAD_USER_MAX) || 50;
|
||||||
const FILE_UPLOAD_USER_WINDOW = parseInt(process.env.FILE_UPLOAD_USER_WINDOW) || 15;
|
const FILE_UPLOAD_USER_WINDOW = parseInt(process.env.FILE_UPLOAD_USER_WINDOW) || 15;
|
||||||
|
const FILE_UPLOAD_VIOLATION_SCORE = process.env.FILE_UPLOAD_VIOLATION_SCORE;
|
||||||
|
|
||||||
const fileUploadIpWindowMs = FILE_UPLOAD_IP_WINDOW * 60 * 1000;
|
const fileUploadIpWindowMs = FILE_UPLOAD_IP_WINDOW * 60 * 1000;
|
||||||
const fileUploadIpMax = FILE_UPLOAD_IP_MAX;
|
const fileUploadIpMax = FILE_UPLOAD_IP_MAX;
|
||||||
@@ -27,6 +25,7 @@ const getEnvironmentVariables = () => {
|
|||||||
fileUploadUserWindowMs,
|
fileUploadUserWindowMs,
|
||||||
fileUploadUserMax,
|
fileUploadUserMax,
|
||||||
fileUploadUserWindowInMinutes,
|
fileUploadUserWindowInMinutes,
|
||||||
|
fileUploadViolationScore: FILE_UPLOAD_VIOLATION_SCORE,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,6 +35,7 @@ const createFileUploadHandler = (ip = true) => {
|
|||||||
fileUploadIpWindowInMinutes,
|
fileUploadIpWindowInMinutes,
|
||||||
fileUploadUserMax,
|
fileUploadUserMax,
|
||||||
fileUploadUserWindowInMinutes,
|
fileUploadUserWindowInMinutes,
|
||||||
|
fileUploadViolationScore,
|
||||||
} = getEnvironmentVariables();
|
} = getEnvironmentVariables();
|
||||||
|
|
||||||
return async (req, res) => {
|
return async (req, res) => {
|
||||||
@@ -47,7 +47,7 @@ const createFileUploadHandler = (ip = true) => {
|
|||||||
windowInMinutes: ip ? fileUploadIpWindowInMinutes : fileUploadUserWindowInMinutes,
|
windowInMinutes: ip ? fileUploadIpWindowInMinutes : fileUploadUserWindowInMinutes,
|
||||||
};
|
};
|
||||||
|
|
||||||
await logViolation(req, res, type, errorMessage);
|
await logViolation(req, res, type, errorMessage, fileUploadViolationScore);
|
||||||
res.status(429).json({ message: 'Too many file upload requests. Try again later' });
|
res.status(429).json({ message: 'Too many file upload requests. Try again later' });
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -60,6 +60,7 @@ const createFileLimiters = () => {
|
|||||||
windowMs: fileUploadIpWindowMs,
|
windowMs: fileUploadIpWindowMs,
|
||||||
max: fileUploadIpMax,
|
max: fileUploadIpMax,
|
||||||
handler: createFileUploadHandler(),
|
handler: createFileUploadHandler(),
|
||||||
|
store: limiterCache('file_upload_ip_limiter'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const userLimiterOptions = {
|
const userLimiterOptions = {
|
||||||
@@ -69,23 +70,9 @@ const createFileLimiters = () => {
|
|||||||
keyGenerator: function (req) {
|
keyGenerator: function (req) {
|
||||||
return req.user?.id; // Use the user ID or NULL if not available
|
return req.user?.id; // Use the user ID or NULL if not available
|
||||||
},
|
},
|
||||||
|
store: limiterCache('file_upload_user_limiter'),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
|
||||||
logger.debug('Using Redis for file upload rate limiters.');
|
|
||||||
const sendCommand = (...args) => ioredisClient.call(...args);
|
|
||||||
const ipStore = new RedisStore({
|
|
||||||
sendCommand,
|
|
||||||
prefix: 'file_upload_ip_limiter:',
|
|
||||||
});
|
|
||||||
const userStore = new RedisStore({
|
|
||||||
sendCommand,
|
|
||||||
prefix: 'file_upload_user_limiter:',
|
|
||||||
});
|
|
||||||
ipLimiterOptions.store = ipStore;
|
|
||||||
userLimiterOptions.store = userStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileUploadIpLimiter = rateLimit(ipLimiterOptions);
|
const fileUploadIpLimiter = rateLimit(ipLimiterOptions);
|
||||||
const fileUploadUserLimiter = rateLimit(userLimiterOptions);
|
const fileUploadUserLimiter = rateLimit(userLimiterOptions);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
|
||||||
const { ViolationTypes } = require('librechat-data-provider');
|
const { ViolationTypes } = require('librechat-data-provider');
|
||||||
const { removePorts, isEnabled } = require('~/server/utils');
|
const { removePorts } = require('~/server/utils');
|
||||||
const ioredisClient = require('~/cache/ioredisClient');
|
const { limiterCache } = require('~/cache/cacheFactory');
|
||||||
const { logViolation } = require('~/cache');
|
const { logViolation } = require('~/cache');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
VERIFY_EMAIL_WINDOW = 2,
|
VERIFY_EMAIL_WINDOW = 2,
|
||||||
@@ -33,17 +31,9 @@ const limiterOptions = {
|
|||||||
max,
|
max,
|
||||||
handler,
|
handler,
|
||||||
keyGenerator: removePorts,
|
keyGenerator: removePorts,
|
||||||
|
store: limiterCache('verify_email_limiter'),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
|
||||||
logger.debug('Using Redis for verify email rate limiter.');
|
|
||||||
const store = new RedisStore({
|
|
||||||
sendCommand: (...args) => ioredisClient.call(...args),
|
|
||||||
prefix: 'verify_email_limiter:',
|
|
||||||
});
|
|
||||||
limiterOptions.store = store;
|
|
||||||
}
|
|
||||||
|
|
||||||
const verifyEmailLimiter = rateLimit(limiterOptions);
|
const verifyEmailLimiter = rateLimit(limiterOptions);
|
||||||
|
|
||||||
module.exports = verifyEmailLimiter;
|
module.exports = verifyEmailLimiter;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const uap = require('ua-parser-js');
|
const uap = require('ua-parser-js');
|
||||||
const { handleError } = require('../utils');
|
const { ViolationTypes } = require('librechat-data-provider');
|
||||||
|
const { handleError } = require('@librechat/api');
|
||||||
const { logViolation } = require('../../cache');
|
const { logViolation } = require('../../cache');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,7 +22,7 @@ async function uaParser(req, res, next) {
|
|||||||
const ua = uap(req.headers['user-agent']);
|
const ua = uap(req.headers['user-agent']);
|
||||||
|
|
||||||
if (!ua.browser.name) {
|
if (!ua.browser.name) {
|
||||||
const type = 'non_browser';
|
const type = ViolationTypes.NON_BROWSER;
|
||||||
await logViolation(req, res, type, { type }, score);
|
await logViolation(req, res, type, { type }, score);
|
||||||
return handleError(res, { message: 'Illegal request' });
|
return handleError(res, { message: 'Illegal request' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { handleError } = require('../utils');
|
const { handleError } = require('@librechat/api');
|
||||||
|
|
||||||
function validateEndpoint(req, res, next) {
|
function validateEndpoint(req, res, next) {
|
||||||
const { endpoint: _endpoint, endpointType } = req.body;
|
const { endpoint: _endpoint, endpointType } = req.body;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
const { handleError } = require('@librechat/api');
|
||||||
const { ViolationTypes } = require('librechat-data-provider');
|
const { ViolationTypes } = require('librechat-data-provider');
|
||||||
const { getModelsConfig } = require('~/server/controllers/ModelController');
|
const { getModelsConfig } = require('~/server/controllers/ModelController');
|
||||||
const { handleError } = require('~/server/utils');
|
|
||||||
const { logViolation } = require('~/cache');
|
const { logViolation } = require('~/cache');
|
||||||
/**
|
/**
|
||||||
* Validates the model of the request.
|
* Validates the model of the request.
|
||||||
|
|||||||
162
api/server/routes/__tests__/static.spec.js
Normal file
162
api/server/routes/__tests__/static.spec.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
const zlib = require('zlib');
|
||||||
|
|
||||||
|
// Create test setup
|
||||||
|
const mockTestDir = path.join(__dirname, 'test-static-route');
|
||||||
|
|
||||||
|
// Mock the paths module to point to our test directory
|
||||||
|
jest.mock('~/config/paths', () => ({
|
||||||
|
imageOutput: mockTestDir,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Static Route Integration', () => {
|
||||||
|
let app;
|
||||||
|
let staticRoute;
|
||||||
|
let testDir;
|
||||||
|
let testImagePath;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// Create a test directory and files
|
||||||
|
testDir = mockTestDir;
|
||||||
|
testImagePath = path.join(testDir, 'test-image.jpg');
|
||||||
|
|
||||||
|
if (!fs.existsSync(testDir)) {
|
||||||
|
fs.mkdirSync(testDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test image file
|
||||||
|
fs.writeFileSync(testImagePath, 'fake-image-data');
|
||||||
|
|
||||||
|
// Create a gzipped version of the test image (for gzip scanning tests)
|
||||||
|
fs.writeFileSync(testImagePath + '.gz', zlib.gzipSync('fake-image-data'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
// Clean up test files
|
||||||
|
if (fs.existsSync(testDir)) {
|
||||||
|
fs.rmSync(testDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to set up static route with specific config
|
||||||
|
const setupStaticRoute = (skipGzipScan = false) => {
|
||||||
|
if (skipGzipScan) {
|
||||||
|
delete process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN;
|
||||||
|
} else {
|
||||||
|
process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN = 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
staticRoute = require('../static');
|
||||||
|
app.use('/images', staticRoute);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear the module cache to get fresh imports
|
||||||
|
jest.resetModules();
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
|
||||||
|
// Clear environment variables
|
||||||
|
delete process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN;
|
||||||
|
delete process.env.NODE_ENV;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('route functionality', () => {
|
||||||
|
it('should serve static image files', async () => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
setupStaticRoute();
|
||||||
|
|
||||||
|
const response = await request(app).get('/images/test-image.jpg').expect(200);
|
||||||
|
|
||||||
|
expect(response.body.toString()).toBe('fake-image-data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent files', async () => {
|
||||||
|
setupStaticRoute();
|
||||||
|
|
||||||
|
const response = await request(app).get('/images/nonexistent.jpg');
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cache behavior', () => {
|
||||||
|
it('should set cache headers for images in production', async () => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
setupStaticRoute();
|
||||||
|
|
||||||
|
const response = await request(app).get('/images/test-image.jpg').expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not set cache headers in development', async () => {
|
||||||
|
process.env.NODE_ENV = 'development';
|
||||||
|
setupStaticRoute();
|
||||||
|
|
||||||
|
const response = await request(app).get('/images/test-image.jpg').expect(200);
|
||||||
|
|
||||||
|
// Our middleware should not set the production cache-control header in development
|
||||||
|
expect(response.headers['cache-control']).not.toBe('public, max-age=172800, s-maxage=86400');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gzip compression behavior', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should serve gzipped files when gzip scanning is enabled', async () => {
|
||||||
|
setupStaticRoute(false); // Enable gzip scanning
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/images/test-image.jpg')
|
||||||
|
.set('Accept-Encoding', 'gzip')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['content-encoding']).toBe('gzip');
|
||||||
|
expect(response.body.toString()).toBe('fake-image-data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not serve gzipped files when gzip scanning is disabled', async () => {
|
||||||
|
setupStaticRoute(true); // Disable gzip scanning
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/images/test-image.jpg')
|
||||||
|
.set('Accept-Encoding', 'gzip')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['content-encoding']).toBeUndefined();
|
||||||
|
expect(response.body.toString()).toBe('fake-image-data');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('path configuration', () => {
|
||||||
|
it('should use the configured imageOutput path', async () => {
|
||||||
|
setupStaticRoute();
|
||||||
|
|
||||||
|
const response = await request(app).get('/images/test-image.jpg').expect(200);
|
||||||
|
|
||||||
|
expect(response.body.toString()).toBe('fake-image-data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should serve from subdirectories', async () => {
|
||||||
|
// Create a subdirectory with a file
|
||||||
|
const subDir = path.join(testDir, 'thumbs');
|
||||||
|
fs.mkdirSync(subDir, { recursive: true });
|
||||||
|
const thumbPath = path.join(subDir, 'thumb.jpg');
|
||||||
|
fs.writeFileSync(thumbPath, 'thumbnail-data');
|
||||||
|
|
||||||
|
setupStaticRoute();
|
||||||
|
|
||||||
|
const response = await request(app).get('/images/thumbs/thumb.jpg').expect(200);
|
||||||
|
|
||||||
|
expect(response.body.toString()).toBe('thumbnail-data');
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
fs.rmSync(subDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -106,6 +106,7 @@ router.get('/', async function (req, res) {
|
|||||||
const serverConfig = config.mcpServers[serverName];
|
const serverConfig = config.mcpServers[serverName];
|
||||||
payload.mcpServers[serverName] = {
|
payload.mcpServers[serverName] = {
|
||||||
customUserVars: serverConfig?.customUserVars || {},
|
customUserVars: serverConfig?.customUserVars || {},
|
||||||
|
chatMenu: serverConfig?.chatMenu,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
282
api/server/routes/files/files.agents.test.js
Normal file
282
api/server/routes/files/files.agents.test.js
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
|
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('~/server/services/Files/process', () => ({
|
||||||
|
processDeleteRequest: jest.fn().mockResolvedValue({}),
|
||||||
|
filterFile: jest.fn(),
|
||||||
|
processFileUpload: jest.fn(),
|
||||||
|
processAgentFileUpload: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Files/strategies', () => ({
|
||||||
|
getStrategyFunctions: jest.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/controllers/assistants/helpers', () => ({
|
||||||
|
getOpenAIClient: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Tools/credentials', () => ({
|
||||||
|
loadAuthValues: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Files/S3/crud', () => ({
|
||||||
|
refreshS3FileUrls: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/cache', () => ({
|
||||||
|
getLogStores: jest.fn(() => ({
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/config', () => ({
|
||||||
|
logger: {
|
||||||
|
error: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
debug: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { createFile } = require('~/models/File');
|
||||||
|
const { createAgent } = require('~/models/Agent');
|
||||||
|
const { getProjectByName } = require('~/models/Project');
|
||||||
|
|
||||||
|
// Import the router after mocks
|
||||||
|
const router = require('./files');
|
||||||
|
|
||||||
|
describe('File Routes - Agent Files Endpoint', () => {
|
||||||
|
let app;
|
||||||
|
let mongoServer;
|
||||||
|
let authorId;
|
||||||
|
let otherUserId;
|
||||||
|
let agentId;
|
||||||
|
let fileId1;
|
||||||
|
let fileId2;
|
||||||
|
let fileId3;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mongoServer = await MongoMemoryServer.create();
|
||||||
|
await mongoose.connect(mongoServer.getUri());
|
||||||
|
|
||||||
|
// Initialize models
|
||||||
|
require('~/db/models');
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Mock authentication middleware
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.user = { id: otherUserId || 'default-user' };
|
||||||
|
req.app = { locals: {} };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/files', router);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await mongoServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Clear database
|
||||||
|
const collections = mongoose.connection.collections;
|
||||||
|
for (const key in collections) {
|
||||||
|
await collections[key].deleteMany({});
|
||||||
|
}
|
||||||
|
|
||||||
|
authorId = new mongoose.Types.ObjectId().toString();
|
||||||
|
otherUserId = new mongoose.Types.ObjectId().toString();
|
||||||
|
agentId = uuidv4();
|
||||||
|
fileId1 = uuidv4();
|
||||||
|
fileId2 = uuidv4();
|
||||||
|
fileId3 = uuidv4();
|
||||||
|
|
||||||
|
// Create files
|
||||||
|
await createFile({
|
||||||
|
user: authorId,
|
||||||
|
file_id: fileId1,
|
||||||
|
filename: 'agent-file1.txt',
|
||||||
|
filepath: `/uploads/${authorId}/${fileId1}`,
|
||||||
|
bytes: 1024,
|
||||||
|
type: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
await createFile({
|
||||||
|
user: authorId,
|
||||||
|
file_id: fileId2,
|
||||||
|
filename: 'agent-file2.txt',
|
||||||
|
filepath: `/uploads/${authorId}/${fileId2}`,
|
||||||
|
bytes: 2048,
|
||||||
|
type: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
await createFile({
|
||||||
|
user: otherUserId,
|
||||||
|
file_id: fileId3,
|
||||||
|
filename: 'user-file.txt',
|
||||||
|
filepath: `/uploads/${otherUserId}/${fileId3}`,
|
||||||
|
bytes: 512,
|
||||||
|
type: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create an agent with files attached
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
author: authorId,
|
||||||
|
model: 'gpt-4',
|
||||||
|
provider: 'openai',
|
||||||
|
isCollaborative: true,
|
||||||
|
tool_resources: {
|
||||||
|
file_search: {
|
||||||
|
file_ids: [fileId1, fileId2],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Share the agent globally
|
||||||
|
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id');
|
||||||
|
if (globalProject) {
|
||||||
|
const { updateAgent } = require('~/models/Agent');
|
||||||
|
await updateAgent({ id: agentId }, { projectIds: [globalProject._id] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /files/agent/:agent_id', () => {
|
||||||
|
it('should return files accessible through the agent for non-author', async () => {
|
||||||
|
const response = await request(app).get(`/files/agent/${agentId}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toHaveLength(2); // Only agent files, not user-owned files
|
||||||
|
|
||||||
|
const fileIds = response.body.map((f) => f.file_id);
|
||||||
|
expect(fileIds).toContain(fileId1);
|
||||||
|
expect(fileIds).toContain(fileId2);
|
||||||
|
expect(fileIds).not.toContain(fileId3); // User's own file not included
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 when agent_id is not provided', async () => {
|
||||||
|
const response = await request(app).get('/files/agent/');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404); // Express returns 404 for missing route parameter
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for non-existent agent', async () => {
|
||||||
|
const response = await request(app).get('/files/agent/non-existent-agent');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual([]); // Empty array for non-existent agent
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when agent is not collaborative', async () => {
|
||||||
|
// Create a non-collaborative agent
|
||||||
|
const nonCollabAgentId = uuidv4();
|
||||||
|
await createAgent({
|
||||||
|
id: nonCollabAgentId,
|
||||||
|
name: 'Non-Collaborative Agent',
|
||||||
|
author: authorId,
|
||||||
|
model: 'gpt-4',
|
||||||
|
provider: 'openai',
|
||||||
|
isCollaborative: false,
|
||||||
|
tool_resources: {
|
||||||
|
file_search: {
|
||||||
|
file_ids: [fileId1],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Share it globally
|
||||||
|
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id');
|
||||||
|
if (globalProject) {
|
||||||
|
const { updateAgent } = require('~/models/Agent');
|
||||||
|
await updateAgent({ id: nonCollabAgentId }, { projectIds: [globalProject._id] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await request(app).get(`/files/agent/${nonCollabAgentId}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual([]); // Empty array when not collaborative
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return agent files for agent author', async () => {
|
||||||
|
// Create a new app instance with author authentication
|
||||||
|
const authorApp = express();
|
||||||
|
authorApp.use(express.json());
|
||||||
|
authorApp.use((req, res, next) => {
|
||||||
|
req.user = { id: authorId };
|
||||||
|
req.app = { locals: {} };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
authorApp.use('/files', router);
|
||||||
|
|
||||||
|
const response = await request(authorApp).get(`/files/agent/${agentId}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toHaveLength(2); // Agent files for author
|
||||||
|
|
||||||
|
const fileIds = response.body.map((f) => f.file_id);
|
||||||
|
expect(fileIds).toContain(fileId1);
|
||||||
|
expect(fileIds).toContain(fileId2);
|
||||||
|
expect(fileIds).not.toContain(fileId3); // User's own file not included
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return files uploaded by other users to shared agent for author', async () => {
|
||||||
|
// Create a file uploaded by another user
|
||||||
|
const otherUserFileId = uuidv4();
|
||||||
|
const anotherUserId = new mongoose.Types.ObjectId().toString();
|
||||||
|
|
||||||
|
await createFile({
|
||||||
|
user: anotherUserId,
|
||||||
|
file_id: otherUserFileId,
|
||||||
|
filename: 'other-user-file.txt',
|
||||||
|
filepath: `/uploads/${anotherUserId}/${otherUserFileId}`,
|
||||||
|
bytes: 4096,
|
||||||
|
type: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update agent to include the file uploaded by another user
|
||||||
|
const { updateAgent } = require('~/models/Agent');
|
||||||
|
await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{
|
||||||
|
tool_resources: {
|
||||||
|
file_search: {
|
||||||
|
file_ids: [fileId1, fileId2, otherUserFileId],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create app instance with author authentication
|
||||||
|
const authorApp = express();
|
||||||
|
authorApp.use(express.json());
|
||||||
|
authorApp.use((req, res, next) => {
|
||||||
|
req.user = { id: authorId };
|
||||||
|
req.app = { locals: {} };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
authorApp.use('/files', router);
|
||||||
|
|
||||||
|
const response = await request(authorApp).get(`/files/agent/${agentId}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toHaveLength(3); // Including file from another user
|
||||||
|
|
||||||
|
const fileIds = response.body.map((f) => f.file_id);
|
||||||
|
expect(fileIds).toContain(fileId1);
|
||||||
|
expect(fileIds).toContain(fileId2);
|
||||||
|
expect(fileIds).toContain(otherUserFileId); // File uploaded by another user
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ const {
|
|||||||
Time,
|
Time,
|
||||||
isUUID,
|
isUUID,
|
||||||
CacheKeys,
|
CacheKeys,
|
||||||
|
Constants,
|
||||||
FileSources,
|
FileSources,
|
||||||
EModelEndpoint,
|
EModelEndpoint,
|
||||||
isAgentsEndpoint,
|
isAgentsEndpoint,
|
||||||
@@ -16,11 +17,12 @@ const {
|
|||||||
processDeleteRequest,
|
processDeleteRequest,
|
||||||
processAgentFileUpload,
|
processAgentFileUpload,
|
||||||
} = require('~/server/services/Files/process');
|
} = require('~/server/services/Files/process');
|
||||||
|
const { getFiles, batchUpdateFiles, hasAccessToFilesViaAgent } = require('~/models/File');
|
||||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||||
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
|
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
|
||||||
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
||||||
const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud');
|
const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud');
|
||||||
const { getFiles, batchUpdateFiles } = require('~/models/File');
|
const { getProjectByName } = require('~/models/Project');
|
||||||
const { getAssistant } = require('~/models/Assistant');
|
const { getAssistant } = require('~/models/Assistant');
|
||||||
const { getAgent } = require('~/models/Agent');
|
const { getAgent } = require('~/models/Agent');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
@@ -50,6 +52,68 @@ router.get('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get files specific to an agent
|
||||||
|
* @route GET /files/agent/:agent_id
|
||||||
|
* @param {string} agent_id - The agent ID to get files for
|
||||||
|
* @returns {Promise<TFile[]>} Array of files attached to the agent
|
||||||
|
*/
|
||||||
|
router.get('/agent/:agent_id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { agent_id } = req.params;
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
if (!agent_id) {
|
||||||
|
return res.status(400).json({ error: 'Agent ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the agent to check ownership and attached files
|
||||||
|
const agent = await getAgent({ id: agent_id });
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
// No agent found, return empty array
|
||||||
|
return res.status(200).json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to the agent
|
||||||
|
if (agent.author.toString() !== userId) {
|
||||||
|
// Non-authors need the agent to be globally shared and collaborative
|
||||||
|
const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, '_id');
|
||||||
|
|
||||||
|
if (
|
||||||
|
!globalProject ||
|
||||||
|
!agent.projectIds.some((pid) => pid.toString() === globalProject._id.toString()) ||
|
||||||
|
!agent.isCollaborative
|
||||||
|
) {
|
||||||
|
return res.status(200).json([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all file IDs from agent's tool resources
|
||||||
|
const agentFileIds = [];
|
||||||
|
if (agent.tool_resources) {
|
||||||
|
for (const [, resource] of Object.entries(agent.tool_resources)) {
|
||||||
|
if (resource?.file_ids && Array.isArray(resource.file_ids)) {
|
||||||
|
agentFileIds.push(...resource.file_ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no files attached to agent, return empty array
|
||||||
|
if (agentFileIds.length === 0) {
|
||||||
|
return res.status(200).json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get only the files attached to this agent
|
||||||
|
const files = await getFiles({ file_id: { $in: agentFileIds } }, null, { text: 0 });
|
||||||
|
|
||||||
|
res.status(200).json(files);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[/files/agent/:agent_id] Error fetching agent files:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch agent files' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/config', async (req, res) => {
|
router.get('/config', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.status(200).json(req.app.locals.fileConfig);
|
res.status(200).json(req.app.locals.fileConfig);
|
||||||
@@ -86,11 +150,62 @@ router.delete('/', async (req, res) => {
|
|||||||
|
|
||||||
const fileIds = files.map((file) => file.file_id);
|
const fileIds = files.map((file) => file.file_id);
|
||||||
const dbFiles = await getFiles({ file_id: { $in: fileIds } });
|
const dbFiles = await getFiles({ file_id: { $in: fileIds } });
|
||||||
const unauthorizedFiles = dbFiles.filter((file) => file.user.toString() !== req.user.id);
|
|
||||||
|
const ownedFiles = [];
|
||||||
|
const nonOwnedFiles = [];
|
||||||
|
const fileMap = new Map();
|
||||||
|
|
||||||
|
for (const file of dbFiles) {
|
||||||
|
fileMap.set(file.file_id, file);
|
||||||
|
if (file.user.toString() === req.user.id) {
|
||||||
|
ownedFiles.push(file);
|
||||||
|
} else {
|
||||||
|
nonOwnedFiles.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all files are owned by the user, no need for further checks
|
||||||
|
if (nonOwnedFiles.length === 0) {
|
||||||
|
await processDeleteRequest({ req, files: ownedFiles });
|
||||||
|
logger.debug(
|
||||||
|
`[/files] Files deleted successfully: ${ownedFiles
|
||||||
|
.filter((f) => f.file_id)
|
||||||
|
.map((f) => f.file_id)
|
||||||
|
.join(', ')}`,
|
||||||
|
);
|
||||||
|
res.status(200).json({ message: 'Files deleted successfully' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access for non-owned files
|
||||||
|
let authorizedFiles = [...ownedFiles];
|
||||||
|
let unauthorizedFiles = [];
|
||||||
|
|
||||||
|
if (req.body.agent_id && nonOwnedFiles.length > 0) {
|
||||||
|
// Batch check access for all non-owned files
|
||||||
|
const nonOwnedFileIds = nonOwnedFiles.map((f) => f.file_id);
|
||||||
|
const accessMap = await hasAccessToFilesViaAgent(
|
||||||
|
req.user.id,
|
||||||
|
nonOwnedFileIds,
|
||||||
|
req.body.agent_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Separate authorized and unauthorized files
|
||||||
|
for (const file of nonOwnedFiles) {
|
||||||
|
if (accessMap.get(file.file_id)) {
|
||||||
|
authorizedFiles.push(file);
|
||||||
|
} else {
|
||||||
|
unauthorizedFiles.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No agent context, all non-owned files are unauthorized
|
||||||
|
unauthorizedFiles = nonOwnedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
if (unauthorizedFiles.length > 0) {
|
if (unauthorizedFiles.length > 0) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
message: 'You can only delete your own files',
|
message: 'You can only delete files you have access to',
|
||||||
unauthorizedFiles: unauthorizedFiles.map((f) => f.file_id),
|
unauthorizedFiles: unauthorizedFiles.map((f) => f.file_id),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -131,10 +246,10 @@ router.delete('/', async (req, res) => {
|
|||||||
.json({ message: 'File associations removed successfully from Azure Assistant' });
|
.json({ message: 'File associations removed successfully from Azure Assistant' });
|
||||||
}
|
}
|
||||||
|
|
||||||
await processDeleteRequest({ req, files: dbFiles });
|
await processDeleteRequest({ req, files: authorizedFiles });
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[/files] Files deleted successfully: ${files
|
`[/files] Files deleted successfully: ${authorizedFiles
|
||||||
.filter((f) => f.file_id)
|
.filter((f) => f.file_id)
|
||||||
.map((f) => f.file_id)
|
.map((f) => f.file_id)
|
||||||
.join(', ')}`,
|
.join(', ')}`,
|
||||||
|
|||||||
302
api/server/routes/files/files.test.js
Normal file
302
api/server/routes/files/files.test.js
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
|
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('~/server/services/Files/process', () => ({
|
||||||
|
processDeleteRequest: jest.fn().mockResolvedValue({}),
|
||||||
|
filterFile: jest.fn(),
|
||||||
|
processFileUpload: jest.fn(),
|
||||||
|
processAgentFileUpload: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Files/strategies', () => ({
|
||||||
|
getStrategyFunctions: jest.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/controllers/assistants/helpers', () => ({
|
||||||
|
getOpenAIClient: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Tools/credentials', () => ({
|
||||||
|
loadAuthValues: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Files/S3/crud', () => ({
|
||||||
|
refreshS3FileUrls: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/cache', () => ({
|
||||||
|
getLogStores: jest.fn(() => ({
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/config', () => ({
|
||||||
|
logger: {
|
||||||
|
error: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
debug: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { createFile } = require('~/models/File');
|
||||||
|
const { createAgent } = require('~/models/Agent');
|
||||||
|
const { getProjectByName } = require('~/models/Project');
|
||||||
|
const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||||
|
|
||||||
|
// Import the router after mocks
|
||||||
|
const router = require('./files');
|
||||||
|
|
||||||
|
describe('File Routes - Delete with Agent Access', () => {
|
||||||
|
let app;
|
||||||
|
let mongoServer;
|
||||||
|
let authorId;
|
||||||
|
let otherUserId;
|
||||||
|
let agentId;
|
||||||
|
let fileId;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mongoServer = await MongoMemoryServer.create();
|
||||||
|
await mongoose.connect(mongoServer.getUri());
|
||||||
|
|
||||||
|
// Initialize models
|
||||||
|
require('~/db/models');
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Mock authentication middleware
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.user = { id: otherUserId || 'default-user' };
|
||||||
|
req.app = { locals: {} };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/files', router);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await mongoServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Clear database
|
||||||
|
const collections = mongoose.connection.collections;
|
||||||
|
for (const key in collections) {
|
||||||
|
await collections[key].deleteMany({});
|
||||||
|
}
|
||||||
|
|
||||||
|
authorId = new mongoose.Types.ObjectId().toString();
|
||||||
|
otherUserId = new mongoose.Types.ObjectId().toString();
|
||||||
|
fileId = uuidv4();
|
||||||
|
|
||||||
|
// Create a file owned by the author
|
||||||
|
await createFile({
|
||||||
|
user: authorId,
|
||||||
|
file_id: fileId,
|
||||||
|
filename: 'test.txt',
|
||||||
|
filepath: `/uploads/${authorId}/${fileId}`,
|
||||||
|
bytes: 1024,
|
||||||
|
type: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create an agent with the file attached
|
||||||
|
const agent = await createAgent({
|
||||||
|
id: uuidv4(),
|
||||||
|
name: 'Test Agent',
|
||||||
|
author: authorId,
|
||||||
|
model: 'gpt-4',
|
||||||
|
provider: 'openai',
|
||||||
|
isCollaborative: true,
|
||||||
|
tool_resources: {
|
||||||
|
file_search: {
|
||||||
|
file_ids: [fileId],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
agentId = agent.id;
|
||||||
|
|
||||||
|
// Share the agent globally
|
||||||
|
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id');
|
||||||
|
if (globalProject) {
|
||||||
|
const { updateAgent } = require('~/models/Agent');
|
||||||
|
await updateAgent({ id: agentId }, { projectIds: [globalProject._id] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /files', () => {
|
||||||
|
it('should allow deleting files owned by the user', async () => {
|
||||||
|
// Create a file owned by the current user
|
||||||
|
const userFileId = uuidv4();
|
||||||
|
await createFile({
|
||||||
|
user: otherUserId,
|
||||||
|
file_id: userFileId,
|
||||||
|
filename: 'user-file.txt',
|
||||||
|
filepath: `/uploads/${otherUserId}/${userFileId}`,
|
||||||
|
bytes: 1024,
|
||||||
|
type: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/files')
|
||||||
|
.send({
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
file_id: userFileId,
|
||||||
|
filepath: `/uploads/${otherUserId}/${userFileId}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.message).toBe('Files deleted successfully');
|
||||||
|
expect(processDeleteRequest).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent deleting files not owned by user without agent context', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/files')
|
||||||
|
.send({
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
file_id: fileId,
|
||||||
|
filepath: `/uploads/${authorId}/${fileId}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body.message).toBe('You can only delete files you have access to');
|
||||||
|
expect(response.body.unauthorizedFiles).toContain(fileId);
|
||||||
|
expect(processDeleteRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow deleting files accessible through shared agent', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/files')
|
||||||
|
.send({
|
||||||
|
agent_id: agentId,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
file_id: fileId,
|
||||||
|
filepath: `/uploads/${authorId}/${fileId}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.message).toBe('Files deleted successfully');
|
||||||
|
expect(processDeleteRequest).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent deleting files not attached to the specified agent', async () => {
|
||||||
|
// Create another file not attached to the agent
|
||||||
|
const unattachedFileId = uuidv4();
|
||||||
|
await createFile({
|
||||||
|
user: authorId,
|
||||||
|
file_id: unattachedFileId,
|
||||||
|
filename: 'unattached.txt',
|
||||||
|
filepath: `/uploads/${authorId}/${unattachedFileId}`,
|
||||||
|
bytes: 1024,
|
||||||
|
type: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/files')
|
||||||
|
.send({
|
||||||
|
agent_id: agentId,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
file_id: unattachedFileId,
|
||||||
|
filepath: `/uploads/${authorId}/${unattachedFileId}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body.message).toBe('You can only delete files you have access to');
|
||||||
|
expect(response.body.unauthorizedFiles).toContain(unattachedFileId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed authorized and unauthorized files', async () => {
|
||||||
|
// Create a file owned by the current user
|
||||||
|
const userFileId = uuidv4();
|
||||||
|
await createFile({
|
||||||
|
user: otherUserId,
|
||||||
|
file_id: userFileId,
|
||||||
|
filename: 'user-file.txt',
|
||||||
|
filepath: `/uploads/${otherUserId}/${userFileId}`,
|
||||||
|
bytes: 1024,
|
||||||
|
type: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create an unauthorized file
|
||||||
|
const unauthorizedFileId = uuidv4();
|
||||||
|
await createFile({
|
||||||
|
user: authorId,
|
||||||
|
file_id: unauthorizedFileId,
|
||||||
|
filename: 'unauthorized.txt',
|
||||||
|
filepath: `/uploads/${authorId}/${unauthorizedFileId}`,
|
||||||
|
bytes: 1024,
|
||||||
|
type: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/files')
|
||||||
|
.send({
|
||||||
|
agent_id: agentId,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
file_id: fileId, // Authorized through agent
|
||||||
|
filepath: `/uploads/${authorId}/${fileId}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file_id: userFileId, // Owned by user
|
||||||
|
filepath: `/uploads/${otherUserId}/${userFileId}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file_id: unauthorizedFileId, // Not authorized
|
||||||
|
filepath: `/uploads/${authorId}/${unauthorizedFileId}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body.message).toBe('You can only delete files you have access to');
|
||||||
|
expect(response.body.unauthorizedFiles).toContain(unauthorizedFileId);
|
||||||
|
expect(response.body.unauthorizedFiles).not.toContain(fileId);
|
||||||
|
expect(response.body.unauthorizedFiles).not.toContain(userFileId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent deleting files when agent is not collaborative', async () => {
|
||||||
|
// Update the agent to be non-collaborative
|
||||||
|
const { updateAgent } = require('~/models/Agent');
|
||||||
|
await updateAgent({ id: agentId }, { isCollaborative: false });
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/files')
|
||||||
|
.send({
|
||||||
|
agent_id: agentId,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
file_id: fileId,
|
||||||
|
filepath: `/uploads/${authorId}/${fileId}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body.message).toBe('You can only delete files you have access to');
|
||||||
|
expect(response.body.unauthorizedFiles).toContain(fileId);
|
||||||
|
expect(processDeleteRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
const { Router } = require('express');
|
const { Router } = require('express');
|
||||||
const { MCPOAuthHandler } = require('@librechat/api');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { CacheKeys } = require('librechat-data-provider');
|
const { MCPOAuthHandler } = require('@librechat/api');
|
||||||
|
const { CacheKeys, Constants } = require('librechat-data-provider');
|
||||||
|
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
|
||||||
|
const { setCachedTools, getCachedTools, loadCustomConfig } = require('~/server/services/Config');
|
||||||
|
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
||||||
|
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||||
const { requireJwtAuth } = require('~/server/middleware');
|
const { requireJwtAuth } = require('~/server/middleware');
|
||||||
const { getFlowStateManager } = require('~/config');
|
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -117,9 +120,73 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
|
|||||||
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager);
|
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager);
|
||||||
logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
|
logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
|
||||||
|
|
||||||
// For system-level OAuth, we need to store the tokens and retry the connection
|
// Try to establish the MCP connection with the new tokens
|
||||||
if (flowState.userId === 'system') {
|
try {
|
||||||
logger.debug(`[MCP OAuth] System-level OAuth completed for ${serverName}`);
|
const mcpManager = getMCPManager(flowState.userId);
|
||||||
|
logger.debug(`[MCP OAuth] Attempting to reconnect ${serverName} with new OAuth tokens`);
|
||||||
|
|
||||||
|
// For user-level OAuth, try to establish the connection
|
||||||
|
if (flowState.userId !== 'system') {
|
||||||
|
// We need to get the user object - in this case we'll need to reconstruct it
|
||||||
|
const user = { id: flowState.userId };
|
||||||
|
|
||||||
|
// Try to establish connection with the new tokens
|
||||||
|
const userConnection = await mcpManager.getUserConnection({
|
||||||
|
user,
|
||||||
|
serverName,
|
||||||
|
flowManager,
|
||||||
|
tokenMethods: {
|
||||||
|
findToken,
|
||||||
|
updateToken,
|
||||||
|
createToken,
|
||||||
|
deleteTokens,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[MCP OAuth] Successfully reconnected ${serverName} for user ${flowState.userId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch and cache tools now that we have a successful connection
|
||||||
|
const userTools = (await getCachedTools({ userId: flowState.userId })) || {};
|
||||||
|
|
||||||
|
// Remove any old tools from this server in the user's cache
|
||||||
|
const mcpDelimiter = Constants.mcp_delimiter;
|
||||||
|
for (const key of Object.keys(userTools)) {
|
||||||
|
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
|
||||||
|
delete userTools[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the new tools from this server
|
||||||
|
const tools = await userConnection.fetchTools();
|
||||||
|
for (const tool of tools) {
|
||||||
|
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
|
||||||
|
userTools[name] = {
|
||||||
|
type: 'function',
|
||||||
|
['function']: {
|
||||||
|
name,
|
||||||
|
description: tool.description,
|
||||||
|
parameters: tool.inputSchema,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the updated user tool cache
|
||||||
|
await setCachedTools(userTools, { userId: flowState.userId });
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`[MCP OAuth] Cached ${tools.length} tools for ${serverName} user ${flowState.userId}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.debug(`[MCP OAuth] System-level OAuth completed for ${serverName}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Don't fail the OAuth callback if reconnection fails - the tokens are still saved
|
||||||
|
logger.warn(
|
||||||
|
`[MCP OAuth] Failed to reconnect ${serverName} after OAuth, but tokens are saved:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ID of the flow that the tool/connection is waiting for */
|
/** ID of the flow that the tool/connection is waiting for */
|
||||||
@@ -202,4 +269,360 @@ router.get('/oauth/status/:flowId', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel OAuth flow
|
||||||
|
* This endpoint cancels a pending OAuth flow
|
||||||
|
*/
|
||||||
|
router.post('/oauth/cancel/:serverName', requireJwtAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { serverName } = req.params;
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
if (!user?.id) {
|
||||||
|
return res.status(401).json({ error: 'User not authenticated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[MCP OAuth Cancel] Cancelling OAuth flow for ${serverName} by user ${user.id}`);
|
||||||
|
|
||||||
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||||
|
const flowManager = getFlowStateManager(flowsCache);
|
||||||
|
|
||||||
|
// Generate the flow ID for this user/server combination
|
||||||
|
const flowId = MCPOAuthHandler.generateFlowId(user.id, serverName);
|
||||||
|
|
||||||
|
// Check if flow exists
|
||||||
|
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
|
||||||
|
|
||||||
|
if (!flowState) {
|
||||||
|
logger.debug(`[MCP OAuth Cancel] No active flow found for ${serverName}`);
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'No active OAuth flow to cancel',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel the flow by marking it as failed
|
||||||
|
await flowManager.completeFlow(flowId, 'mcp_oauth', null, 'User cancelled OAuth flow');
|
||||||
|
|
||||||
|
logger.info(`[MCP OAuth Cancel] Successfully cancelled OAuth flow for ${serverName}`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `OAuth flow for ${serverName} cancelled successfully`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[MCP OAuth Cancel] Failed to cancel OAuth flow', error);
|
||||||
|
res.status(500).json({ error: 'Failed to cancel OAuth flow' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reinitialize MCP server
|
||||||
|
* This endpoint allows reinitializing a specific MCP server
|
||||||
|
*/
|
||||||
|
router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { serverName } = req.params;
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
if (!user?.id) {
|
||||||
|
return res.status(401).json({ error: 'User not authenticated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[MCP Reinitialize] Reinitializing server: ${serverName}`);
|
||||||
|
|
||||||
|
const printConfig = false;
|
||||||
|
const config = await loadCustomConfig(printConfig);
|
||||||
|
if (!config || !config.mcpServers || !config.mcpServers[serverName]) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: `MCP server '${serverName}' not found in configuration`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||||
|
const flowManager = getFlowStateManager(flowsCache);
|
||||||
|
const mcpManager = getMCPManager();
|
||||||
|
|
||||||
|
await mcpManager.disconnectServer(serverName);
|
||||||
|
logger.info(`[MCP Reinitialize] Disconnected existing server: ${serverName}`);
|
||||||
|
|
||||||
|
const serverConfig = config.mcpServers[serverName];
|
||||||
|
mcpManager.mcpConfigs[serverName] = serverConfig;
|
||||||
|
let customUserVars = {};
|
||||||
|
if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
|
||||||
|
for (const varName of Object.keys(serverConfig.customUserVars)) {
|
||||||
|
try {
|
||||||
|
const value = await getUserPluginAuthValue(user.id, varName, false);
|
||||||
|
if (value) {
|
||||||
|
customUserVars[varName] = value;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[MCP Reinitialize] Error fetching ${varName} for user ${user.id}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let userConnection = null;
|
||||||
|
let oauthRequired = false;
|
||||||
|
let oauthUrl = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
userConnection = await mcpManager.getUserConnection({
|
||||||
|
user,
|
||||||
|
serverName,
|
||||||
|
flowManager,
|
||||||
|
customUserVars,
|
||||||
|
tokenMethods: {
|
||||||
|
findToken,
|
||||||
|
updateToken,
|
||||||
|
createToken,
|
||||||
|
deleteTokens,
|
||||||
|
},
|
||||||
|
returnOnOAuth: true, // Return immediately when OAuth is initiated
|
||||||
|
// Add OAuth handlers to capture the OAuth URL when needed
|
||||||
|
oauthStart: async (authURL) => {
|
||||||
|
logger.info(`[MCP Reinitialize] OAuth URL received: ${authURL}`);
|
||||||
|
oauthUrl = authURL;
|
||||||
|
oauthRequired = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`[MCP Reinitialize] Successfully established connection for ${serverName}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.info(`[MCP Reinitialize] getUserConnection threw error: ${err.message}`);
|
||||||
|
logger.info(
|
||||||
|
`[MCP Reinitialize] OAuth state - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if this is an OAuth error - if so, the flow state should be set up now
|
||||||
|
const isOAuthError =
|
||||||
|
err.message?.includes('OAuth') ||
|
||||||
|
err.message?.includes('authentication') ||
|
||||||
|
err.message?.includes('401');
|
||||||
|
|
||||||
|
const isOAuthFlowInitiated = err.message === 'OAuth flow initiated - return early';
|
||||||
|
|
||||||
|
if (isOAuthError || oauthRequired || isOAuthFlowInitiated) {
|
||||||
|
logger.info(
|
||||||
|
`[MCP Reinitialize] OAuth required for ${serverName} (isOAuthError: ${isOAuthError}, oauthRequired: ${oauthRequired}, isOAuthFlowInitiated: ${isOAuthFlowInitiated})`,
|
||||||
|
);
|
||||||
|
oauthRequired = true;
|
||||||
|
// Don't return error - continue so frontend can handle OAuth
|
||||||
|
} else {
|
||||||
|
logger.error(
|
||||||
|
`[MCP Reinitialize] Error initializing MCP server ${serverName} for user:`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
return res.status(500).json({ error: 'Failed to reinitialize MCP server for user' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only fetch and cache tools if we successfully connected (no OAuth required)
|
||||||
|
if (userConnection && !oauthRequired) {
|
||||||
|
const userTools = (await getCachedTools({ userId: user.id })) || {};
|
||||||
|
|
||||||
|
// Remove any old tools from this server in the user's cache
|
||||||
|
const mcpDelimiter = Constants.mcp_delimiter;
|
||||||
|
for (const key of Object.keys(userTools)) {
|
||||||
|
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
|
||||||
|
delete userTools[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the new tools from this server
|
||||||
|
const tools = await userConnection.fetchTools();
|
||||||
|
for (const tool of tools) {
|
||||||
|
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
|
||||||
|
userTools[name] = {
|
||||||
|
type: 'function',
|
||||||
|
['function']: {
|
||||||
|
name,
|
||||||
|
description: tool.description,
|
||||||
|
parameters: tool.inputSchema,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the updated user tool cache
|
||||||
|
await setCachedTools(userTools, { userId: user.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`[MCP Reinitialize] Sending response for ${serverName} - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: oauthRequired
|
||||||
|
? `MCP server '${serverName}' ready for OAuth authentication`
|
||||||
|
: `MCP server '${serverName}' reinitialized successfully`,
|
||||||
|
serverName,
|
||||||
|
oauthRequired,
|
||||||
|
oauthUrl,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[MCP Reinitialize] Unexpected error', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get connection status for all MCP servers
|
||||||
|
* This endpoint returns the actual connection status from MCPManager without disconnecting idle connections
|
||||||
|
*/
|
||||||
|
router.get('/connection/status', requireJwtAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
if (!user?.id) {
|
||||||
|
return res.status(401).json({ error: 'User not authenticated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mcpManager = getMCPManager(user.id);
|
||||||
|
const connectionStatus = {};
|
||||||
|
|
||||||
|
const printConfig = false;
|
||||||
|
const config = await loadCustomConfig(printConfig);
|
||||||
|
const mcpConfig = config?.mcpServers;
|
||||||
|
|
||||||
|
const appConnections = mcpManager.getAllConnections() || new Map();
|
||||||
|
const userConnections = mcpManager.getUserConnections(user.id) || new Map();
|
||||||
|
const oauthServers = mcpManager.getOAuthServers() || new Set();
|
||||||
|
|
||||||
|
if (!mcpConfig) {
|
||||||
|
return res.status(404).json({ error: 'MCP config not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get flow manager to check for active/timed-out OAuth flows
|
||||||
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||||
|
const flowManager = getFlowStateManager(flowsCache);
|
||||||
|
|
||||||
|
for (const [serverName] of Object.entries(mcpConfig)) {
|
||||||
|
const getConnectionState = (serverName) =>
|
||||||
|
appConnections.get(serverName)?.connectionState ??
|
||||||
|
userConnections.get(serverName)?.connectionState ??
|
||||||
|
'disconnected';
|
||||||
|
|
||||||
|
const baseConnectionState = getConnectionState(serverName);
|
||||||
|
|
||||||
|
let hasActiveOAuthFlow = false;
|
||||||
|
let hasFailedOAuthFlow = false;
|
||||||
|
|
||||||
|
if (baseConnectionState === 'disconnected' && oauthServers.has(serverName)) {
|
||||||
|
try {
|
||||||
|
// Check for user-specific OAuth flows
|
||||||
|
const flowId = MCPOAuthHandler.generateFlowId(user.id, serverName);
|
||||||
|
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
|
||||||
|
if (flowState) {
|
||||||
|
// Check if flow failed or timed out
|
||||||
|
const flowAge = Date.now() - flowState.createdAt;
|
||||||
|
const flowTTL = flowState.ttl || 180000; // Default 3 minutes
|
||||||
|
|
||||||
|
if (flowState.status === 'FAILED' || flowAge > flowTTL) {
|
||||||
|
hasFailedOAuthFlow = true;
|
||||||
|
logger.debug(`[MCP Connection Status] Found failed OAuth flow for ${serverName}`, {
|
||||||
|
flowId,
|
||||||
|
status: flowState.status,
|
||||||
|
flowAge,
|
||||||
|
flowTTL,
|
||||||
|
timedOut: flowAge > flowTTL,
|
||||||
|
});
|
||||||
|
} else if (flowState.status === 'PENDING') {
|
||||||
|
hasActiveOAuthFlow = true;
|
||||||
|
logger.debug(`[MCP Connection Status] Found active OAuth flow for ${serverName}`, {
|
||||||
|
flowId,
|
||||||
|
flowAge,
|
||||||
|
flowTTL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[MCP Connection Status] Error checking OAuth flows for ${serverName}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the final connection state
|
||||||
|
let finalConnectionState = baseConnectionState;
|
||||||
|
if (hasFailedOAuthFlow) {
|
||||||
|
finalConnectionState = 'error'; // Report as error if OAuth failed
|
||||||
|
} else if (hasActiveOAuthFlow && baseConnectionState === 'disconnected') {
|
||||||
|
finalConnectionState = 'connecting'; // Still waiting for OAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionStatus[serverName] = {
|
||||||
|
requiresOAuth: oauthServers.has(serverName),
|
||||||
|
connectionState: finalConnectionState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
connectionStatus,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[MCP Connection Status] Failed to get connection status', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get connection status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check which authentication values exist for a specific MCP server
|
||||||
|
* This endpoint returns only boolean flags indicating if values are set, not the actual values
|
||||||
|
*/
|
||||||
|
router.get('/:serverName/auth-values', requireJwtAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { serverName } = req.params;
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
if (!user?.id) {
|
||||||
|
return res.status(401).json({ error: 'User not authenticated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const printConfig = false;
|
||||||
|
const config = await loadCustomConfig(printConfig);
|
||||||
|
if (!config || !config.mcpServers || !config.mcpServers[serverName]) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: `MCP server '${serverName}' not found in configuration`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverConfig = config.mcpServers[serverName];
|
||||||
|
const pluginKey = `${Constants.mcp_prefix}${serverName}`;
|
||||||
|
const authValueFlags = {};
|
||||||
|
|
||||||
|
// Check existence of saved values for each custom user variable (don't fetch actual values)
|
||||||
|
if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
|
||||||
|
for (const varName of Object.keys(serverConfig.customUserVars)) {
|
||||||
|
try {
|
||||||
|
const value = await getUserPluginAuthValue(user.id, varName, false, pluginKey);
|
||||||
|
// Only store boolean flag indicating if value exists
|
||||||
|
authValueFlags[varName] = !!(value && value.length > 0);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`[MCP Auth Value Flags] Error checking ${varName} for user ${user.id}:`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
// Default to false if we can't check
|
||||||
|
authValueFlags[varName] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
serverName,
|
||||||
|
authValueFlags,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[MCP Auth Value Flags] Failed to check auth value flags for ${req.params.serverName}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
res.status(500).json({ error: 'Failed to check auth value flags' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -172,40 +172,68 @@ router.patch('/preferences', checkMemoryOptOut, async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* PATCH /memories/:key
|
* PATCH /memories/:key
|
||||||
* Updates the value of an existing memory entry for the authenticated user.
|
* Updates the value of an existing memory entry for the authenticated user.
|
||||||
* Body: { value: string }
|
* Body: { key?: string, value: string }
|
||||||
* Returns 200 and { updated: true, memory: <updatedDoc> } when successful.
|
* Returns 200 and { updated: true, memory: <updatedDoc> } when successful.
|
||||||
*/
|
*/
|
||||||
router.patch('/:key', checkMemoryUpdate, async (req, res) => {
|
router.patch('/:key', checkMemoryUpdate, async (req, res) => {
|
||||||
const { key } = req.params;
|
const { key: urlKey } = req.params;
|
||||||
const { value } = req.body || {};
|
const { key: bodyKey, value } = req.body || {};
|
||||||
|
|
||||||
if (typeof value !== 'string' || value.trim() === '') {
|
if (typeof value !== 'string' || value.trim() === '') {
|
||||||
return res.status(400).json({ error: 'Value is required and must be a non-empty string.' });
|
return res.status(400).json({ error: 'Value is required and must be a non-empty string.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use the key from the body if provided, otherwise use the key from the URL
|
||||||
|
const newKey = bodyKey || urlKey;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
|
const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
|
||||||
|
|
||||||
const memories = await getAllUserMemories(req.user.id);
|
const memories = await getAllUserMemories(req.user.id);
|
||||||
const existingMemory = memories.find((m) => m.key === key);
|
const existingMemory = memories.find((m) => m.key === urlKey);
|
||||||
|
|
||||||
if (!existingMemory) {
|
if (!existingMemory) {
|
||||||
return res.status(404).json({ error: 'Memory not found.' });
|
return res.status(404).json({ error: 'Memory not found.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await setMemory({
|
// If the key is changing, we need to handle it specially
|
||||||
userId: req.user.id,
|
if (newKey !== urlKey) {
|
||||||
key,
|
const keyExists = memories.find((m) => m.key === newKey);
|
||||||
value,
|
if (keyExists) {
|
||||||
tokenCount,
|
return res.status(409).json({ error: 'Memory with this key already exists.' });
|
||||||
});
|
}
|
||||||
|
|
||||||
if (!result.ok) {
|
const createResult = await createMemory({
|
||||||
return res.status(500).json({ error: 'Failed to update memory.' });
|
userId: req.user.id,
|
||||||
|
key: newKey,
|
||||||
|
value,
|
||||||
|
tokenCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createResult.ok) {
|
||||||
|
return res.status(500).json({ error: 'Failed to create new memory.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteResult = await deleteMemory({ userId: req.user.id, key: urlKey });
|
||||||
|
if (!deleteResult.ok) {
|
||||||
|
return res.status(500).json({ error: 'Failed to delete old memory.' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Key is not changing, just update the value
|
||||||
|
const result = await setMemory({
|
||||||
|
userId: req.user.id,
|
||||||
|
key: newKey,
|
||||||
|
value,
|
||||||
|
tokenCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return res.status(500).json({ error: 'Failed to update memory.' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedMemories = await getAllUserMemories(req.user.id);
|
const updatedMemories = await getAllUserMemories(req.user.id);
|
||||||
const updatedMemory = updatedMemories.find((m) => m.key === key);
|
const updatedMemory = updatedMemories.find((m) => m.key === newKey);
|
||||||
|
|
||||||
res.json({ updated: true, memory: updatedMemory });
|
res.json({ updated: true, memory: updatedMemory });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const staticCache = require('../utils/staticCache');
|
const staticCache = require('../utils/staticCache');
|
||||||
const paths = require('~/config/paths');
|
const paths = require('~/config/paths');
|
||||||
|
const { isEnabled } = require('~/server/utils');
|
||||||
|
|
||||||
|
const skipGzipScan = !isEnabled(process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN);
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(staticCache(paths.imageOutput));
|
router.use(staticCache(paths.imageOutput, { skipGzipScan }));
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
|
const { agentsConfigSetup, loadWebSearchConfig } = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
FileSources,
|
FileSources,
|
||||||
loadOCRConfig,
|
loadOCRConfig,
|
||||||
EModelEndpoint,
|
EModelEndpoint,
|
||||||
loadMemoryConfig,
|
loadMemoryConfig,
|
||||||
getConfigDefaults,
|
getConfigDefaults,
|
||||||
loadWebSearchConfig,
|
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { agentsConfigSetup } = require('@librechat/api');
|
|
||||||
const {
|
const {
|
||||||
checkHealth,
|
checkHealth,
|
||||||
checkConfig,
|
checkConfig,
|
||||||
@@ -158,6 +157,10 @@ const AppService = async (app) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (endpoints?.all) {
|
||||||
|
endpointLocals.all = endpoints.all;
|
||||||
|
}
|
||||||
|
|
||||||
app.locals = {
|
app.locals = {
|
||||||
...defaultLocals,
|
...defaultLocals,
|
||||||
fileConfig: config?.fileConfig,
|
fileConfig: config?.fileConfig,
|
||||||
|
|||||||
@@ -543,6 +543,206 @@ describe('AppService', () => {
|
|||||||
expect(process.env.IMPORT_USER_MAX).toEqual('initialUserMax');
|
expect(process.env.IMPORT_USER_MAX).toEqual('initialUserMax');
|
||||||
expect(process.env.IMPORT_USER_WINDOW).toEqual('initialUserWindow');
|
expect(process.env.IMPORT_USER_WINDOW).toEqual('initialUserWindow');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should correctly configure endpoint with titlePrompt, titleMethod, and titlePromptTemplate', async () => {
|
||||||
|
require('./Config/loadCustomConfig').mockImplementationOnce(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
endpoints: {
|
||||||
|
[EModelEndpoint.openAI]: {
|
||||||
|
titleConvo: true,
|
||||||
|
titleModel: 'gpt-3.5-turbo',
|
||||||
|
titleMethod: 'structured',
|
||||||
|
titlePrompt: 'Custom title prompt for conversation',
|
||||||
|
titlePromptTemplate: 'Summarize this conversation: {{conversation}}',
|
||||||
|
},
|
||||||
|
[EModelEndpoint.assistants]: {
|
||||||
|
titleMethod: 'functions',
|
||||||
|
titlePrompt: 'Generate a title for this assistant conversation',
|
||||||
|
titlePromptTemplate: 'Assistant conversation template: {{messages}}',
|
||||||
|
},
|
||||||
|
[EModelEndpoint.azureOpenAI]: {
|
||||||
|
groups: azureGroups,
|
||||||
|
titleConvo: true,
|
||||||
|
titleMethod: 'completion',
|
||||||
|
titleModel: 'gpt-4',
|
||||||
|
titlePrompt: 'Azure title prompt',
|
||||||
|
titlePromptTemplate: 'Azure conversation: {{context}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await AppService(app);
|
||||||
|
|
||||||
|
// Check OpenAI endpoint configuration
|
||||||
|
expect(app.locals).toHaveProperty(EModelEndpoint.openAI);
|
||||||
|
expect(app.locals[EModelEndpoint.openAI]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
titleConvo: true,
|
||||||
|
titleModel: 'gpt-3.5-turbo',
|
||||||
|
titleMethod: 'structured',
|
||||||
|
titlePrompt: 'Custom title prompt for conversation',
|
||||||
|
titlePromptTemplate: 'Summarize this conversation: {{conversation}}',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check Assistants endpoint configuration
|
||||||
|
expect(app.locals).toHaveProperty(EModelEndpoint.assistants);
|
||||||
|
expect(app.locals[EModelEndpoint.assistants]).toMatchObject({
|
||||||
|
titleMethod: 'functions',
|
||||||
|
titlePrompt: 'Generate a title for this assistant conversation',
|
||||||
|
titlePromptTemplate: 'Assistant conversation template: {{messages}}',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check Azure OpenAI endpoint configuration
|
||||||
|
expect(app.locals).toHaveProperty(EModelEndpoint.azureOpenAI);
|
||||||
|
expect(app.locals[EModelEndpoint.azureOpenAI]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
titleConvo: true,
|
||||||
|
titleMethod: 'completion',
|
||||||
|
titleModel: 'gpt-4',
|
||||||
|
titlePrompt: 'Azure title prompt',
|
||||||
|
titlePromptTemplate: 'Azure conversation: {{context}}',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should configure Agent endpoint with title generation settings', async () => {
|
||||||
|
require('./Config/loadCustomConfig').mockImplementationOnce(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
endpoints: {
|
||||||
|
[EModelEndpoint.agents]: {
|
||||||
|
disableBuilder: false,
|
||||||
|
titleConvo: true,
|
||||||
|
titleModel: 'gpt-4',
|
||||||
|
titleMethod: 'structured',
|
||||||
|
titlePrompt: 'Generate a descriptive title for this agent conversation',
|
||||||
|
titlePromptTemplate: 'Agent conversation summary: {{content}}',
|
||||||
|
recursionLimit: 15,
|
||||||
|
capabilities: [AgentCapabilities.tools, AgentCapabilities.actions],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await AppService(app);
|
||||||
|
|
||||||
|
expect(app.locals).toHaveProperty(EModelEndpoint.agents);
|
||||||
|
expect(app.locals[EModelEndpoint.agents]).toMatchObject({
|
||||||
|
disableBuilder: false,
|
||||||
|
titleConvo: true,
|
||||||
|
titleModel: 'gpt-4',
|
||||||
|
titleMethod: 'structured',
|
||||||
|
titlePrompt: 'Generate a descriptive title for this agent conversation',
|
||||||
|
titlePromptTemplate: 'Agent conversation summary: {{content}}',
|
||||||
|
recursionLimit: 15,
|
||||||
|
capabilities: expect.arrayContaining([AgentCapabilities.tools, AgentCapabilities.actions]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing title configuration options with defaults', async () => {
|
||||||
|
require('./Config/loadCustomConfig').mockImplementationOnce(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
endpoints: {
|
||||||
|
[EModelEndpoint.openAI]: {
|
||||||
|
titleConvo: true,
|
||||||
|
// titlePrompt and titlePromptTemplate are not provided
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await AppService(app);
|
||||||
|
|
||||||
|
expect(app.locals).toHaveProperty(EModelEndpoint.openAI);
|
||||||
|
expect(app.locals[EModelEndpoint.openAI]).toMatchObject({
|
||||||
|
titleConvo: true,
|
||||||
|
});
|
||||||
|
// Check that the optional fields are undefined when not provided
|
||||||
|
expect(app.locals[EModelEndpoint.openAI].titlePrompt).toBeUndefined();
|
||||||
|
expect(app.locals[EModelEndpoint.openAI].titlePromptTemplate).toBeUndefined();
|
||||||
|
expect(app.locals[EModelEndpoint.openAI].titleMethod).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly configure titleEndpoint when specified', async () => {
|
||||||
|
require('./Config/loadCustomConfig').mockImplementationOnce(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
endpoints: {
|
||||||
|
[EModelEndpoint.openAI]: {
|
||||||
|
titleConvo: true,
|
||||||
|
titleModel: 'gpt-3.5-turbo',
|
||||||
|
titleEndpoint: EModelEndpoint.anthropic,
|
||||||
|
titlePrompt: 'Generate a concise title',
|
||||||
|
},
|
||||||
|
[EModelEndpoint.agents]: {
|
||||||
|
titleEndpoint: 'custom-provider',
|
||||||
|
titleMethod: 'structured',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await AppService(app);
|
||||||
|
|
||||||
|
// Check OpenAI endpoint has titleEndpoint
|
||||||
|
expect(app.locals).toHaveProperty(EModelEndpoint.openAI);
|
||||||
|
expect(app.locals[EModelEndpoint.openAI]).toMatchObject({
|
||||||
|
titleConvo: true,
|
||||||
|
titleModel: 'gpt-3.5-turbo',
|
||||||
|
titleEndpoint: EModelEndpoint.anthropic,
|
||||||
|
titlePrompt: 'Generate a concise title',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check Agents endpoint has titleEndpoint
|
||||||
|
expect(app.locals).toHaveProperty(EModelEndpoint.agents);
|
||||||
|
expect(app.locals[EModelEndpoint.agents]).toMatchObject({
|
||||||
|
titleEndpoint: 'custom-provider',
|
||||||
|
titleMethod: 'structured',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly configure all endpoint when specified', async () => {
|
||||||
|
require('./Config/loadCustomConfig').mockImplementationOnce(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
endpoints: {
|
||||||
|
all: {
|
||||||
|
titleConvo: true,
|
||||||
|
titleModel: 'gpt-4o-mini',
|
||||||
|
titleMethod: 'structured',
|
||||||
|
titlePrompt: 'Default title prompt for all endpoints',
|
||||||
|
titlePromptTemplate: 'Default template: {{conversation}}',
|
||||||
|
titleEndpoint: EModelEndpoint.anthropic,
|
||||||
|
streamRate: 50,
|
||||||
|
},
|
||||||
|
[EModelEndpoint.openAI]: {
|
||||||
|
titleConvo: true,
|
||||||
|
titleModel: 'gpt-3.5-turbo',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await AppService(app);
|
||||||
|
|
||||||
|
// Check that 'all' endpoint config is loaded
|
||||||
|
expect(app.locals).toHaveProperty('all');
|
||||||
|
expect(app.locals.all).toMatchObject({
|
||||||
|
titleConvo: true,
|
||||||
|
titleModel: 'gpt-4o-mini',
|
||||||
|
titleMethod: 'structured',
|
||||||
|
titlePrompt: 'Default title prompt for all endpoints',
|
||||||
|
titlePromptTemplate: 'Default template: {{conversation}}',
|
||||||
|
titleEndpoint: EModelEndpoint.anthropic,
|
||||||
|
streamRate: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that OpenAI endpoint has its own config
|
||||||
|
expect(app.locals).toHaveProperty(EModelEndpoint.openAI);
|
||||||
|
expect(app.locals[EModelEndpoint.openAI]).toMatchObject({
|
||||||
|
titleConvo: true,
|
||||||
|
titleModel: 'gpt-3.5-turbo',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('AppService updating app.locals and issuing warnings', () => {
|
describe('AppService updating app.locals and issuing warnings', () => {
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { getUserMCPAuthMap } = require('@librechat/api');
|
const { isEnabled, getUserMCPAuthMap } = require('@librechat/api');
|
||||||
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
|
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
|
||||||
const { normalizeEndpointName, isEnabled } = require('~/server/utils');
|
const { normalizeEndpointName } = require('~/server/utils');
|
||||||
const loadCustomConfig = require('./loadCustomConfig');
|
const loadCustomConfig = require('./loadCustomConfig');
|
||||||
const { getCachedTools } = require('./getCachedTools');
|
|
||||||
const { findPluginAuthsByKeys } = require('~/models');
|
|
||||||
const getLogStores = require('~/cache/getLogStores');
|
const getLogStores = require('~/cache/getLogStores');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,8 +11,8 @@ const getLogStores = require('~/cache/getLogStores');
|
|||||||
* @returns {Promise<TCustomConfig | null>}
|
* @returns {Promise<TCustomConfig | null>}
|
||||||
* */
|
* */
|
||||||
async function getCustomConfig() {
|
async function getCustomConfig() {
|
||||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
const cache = getLogStores(CacheKeys.STATIC_CONFIG);
|
||||||
return (await cache.get(CacheKeys.CUSTOM_CONFIG)) || (await loadCustomConfig());
|
return (await cache.get(CacheKeys.LIBRECHAT_YAML_CONFIG)) || (await loadCustomConfig());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,46 +53,44 @@ const getCustomEndpointConfig = async (endpoint) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
async function createGetMCPAuthMap() {
|
/**
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {string} params.userId
|
||||||
|
* @param {GenericTool[]} [params.tools]
|
||||||
|
* @param {import('@librechat/data-schemas').PluginAuthMethods['findPluginAuthsByKeys']} params.findPluginAuthsByKeys
|
||||||
|
* @returns {Promise<Record<string, Record<string, string>> | undefined>}
|
||||||
|
*/
|
||||||
|
async function getMCPAuthMap({ userId, tools, findPluginAuthsByKeys }) {
|
||||||
|
try {
|
||||||
|
if (!tools || tools.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return await getUserMCPAuthMap({
|
||||||
|
tools,
|
||||||
|
userId,
|
||||||
|
findPluginAuthsByKeys,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async function hasCustomUserVars() {
|
||||||
const customConfig = await getCustomConfig();
|
const customConfig = await getCustomConfig();
|
||||||
const mcpServers = customConfig?.mcpServers;
|
const mcpServers = customConfig?.mcpServers;
|
||||||
const hasCustomUserVars = Object.values(mcpServers ?? {}).some((server) => server.customUserVars);
|
return Object.values(mcpServers ?? {}).some((server) => server.customUserVars);
|
||||||
if (!hasCustomUserVars) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Object} params
|
|
||||||
* @param {GenericTool[]} [params.tools]
|
|
||||||
* @param {string} params.userId
|
|
||||||
* @returns {Promise<Record<string, Record<string, string>> | undefined>}
|
|
||||||
*/
|
|
||||||
return async function ({ tools, userId }) {
|
|
||||||
try {
|
|
||||||
if (!tools || tools.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const appTools = await getCachedTools({
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
return await getUserMCPAuthMap({
|
|
||||||
tools,
|
|
||||||
userId,
|
|
||||||
appTools,
|
|
||||||
findPluginAuthsByKeys,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
`[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent`,
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
getMCPAuthMap,
|
||||||
getCustomConfig,
|
getCustomConfig,
|
||||||
getBalanceConfig,
|
getBalanceConfig,
|
||||||
createGetMCPAuthMap,
|
hasCustomUserVars,
|
||||||
getCustomEndpointConfig,
|
getCustomEndpointConfig,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ async function loadAsyncEndpoints(req) {
|
|||||||
} else {
|
} else {
|
||||||
/** Only attempt to load service key if GOOGLE_KEY is not provided */
|
/** Only attempt to load service key if GOOGLE_KEY is not provided */
|
||||||
const serviceKeyPath =
|
const serviceKeyPath =
|
||||||
process.env.GOOGLE_SERVICE_KEY_FILE_PATH ||
|
process.env.GOOGLE_SERVICE_KEY_FILE || path.join(__dirname, '../../..', 'data', 'auth.json');
|
||||||
path.join(__dirname, '../../..', 'data', 'auth.json');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
serviceKey = await loadServiceKey(serviceKeyPath);
|
serviceKey = await loadServiceKey(serviceKeyPath);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ let i = 0;
|
|||||||
* @function loadCustomConfig
|
* @function loadCustomConfig
|
||||||
* @returns {Promise<TCustomConfig | null>} A promise that resolves to null or the custom config object.
|
* @returns {Promise<TCustomConfig | null>} A promise that resolves to null or the custom config object.
|
||||||
* */
|
* */
|
||||||
async function loadCustomConfig() {
|
async function loadCustomConfig(printConfig = true) {
|
||||||
// Use CONFIG_PATH if set, otherwise fallback to defaultConfigPath
|
// Use CONFIG_PATH if set, otherwise fallback to defaultConfigPath
|
||||||
const configPath = process.env.CONFIG_PATH || defaultConfigPath;
|
const configPath = process.env.CONFIG_PATH || defaultConfigPath;
|
||||||
|
|
||||||
@@ -108,9 +108,11 @@ https://www.librechat.ai/docs/configuration/stt_tts`);
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
} else {
|
} else {
|
||||||
logger.info('Custom config file loaded:');
|
if (printConfig) {
|
||||||
logger.info(JSON.stringify(customConfig, null, 2));
|
logger.info('Custom config file loaded:');
|
||||||
logger.debug('Custom config:', customConfig);
|
logger.info(JSON.stringify(customConfig, null, 2));
|
||||||
|
logger.debug('Custom config:', customConfig);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(customConfig.endpoints?.custom ?? [])
|
(customConfig.endpoints?.custom ?? [])
|
||||||
@@ -118,8 +120,8 @@ https://www.librechat.ai/docs/configuration/stt_tts`);
|
|||||||
.forEach((endpoint) => parseCustomParams(endpoint.name, endpoint.customParams));
|
.forEach((endpoint) => parseCustomParams(endpoint.name, endpoint.customParams));
|
||||||
|
|
||||||
if (customConfig.cache) {
|
if (customConfig.cache) {
|
||||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
const cache = getLogStores(CacheKeys.STATIC_CONFIG);
|
||||||
await cache.set(CacheKeys.CUSTOM_CONFIG, customConfig);
|
await cache.set(CacheKeys.LIBRECHAT_YAML_CONFIG, customConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.data.modelSpecs) {
|
if (result.data.modelSpecs) {
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ const {
|
|||||||
ErrorTypes,
|
ErrorTypes,
|
||||||
EModelEndpoint,
|
EModelEndpoint,
|
||||||
EToolResources,
|
EToolResources,
|
||||||
|
isAgentsEndpoint,
|
||||||
replaceSpecialVars,
|
replaceSpecialVars,
|
||||||
providerEndpointMap,
|
providerEndpointMap,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { getProviderConfig } = require('~/server/services/Endpoints');
|
|
||||||
const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
|
const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
|
||||||
|
const { getProviderConfig } = require('~/server/services/Endpoints');
|
||||||
const { processFiles } = require('~/server/services/Files/process');
|
const { processFiles } = require('~/server/services/Files/process');
|
||||||
const { getFiles, getToolFilesByIds } = require('~/models/File');
|
const { getFiles, getToolFilesByIds } = require('~/models/File');
|
||||||
const { getConvoFiles } = require('~/models/Conversation');
|
const { getConvoFiles } = require('~/models/Conversation');
|
||||||
@@ -42,7 +43,11 @@ const initializeAgent = async ({
|
|||||||
allowedProviders,
|
allowedProviders,
|
||||||
isInitialAgent = false,
|
isInitialAgent = false,
|
||||||
}) => {
|
}) => {
|
||||||
if (allowedProviders.size > 0 && !allowedProviders.has(agent.provider)) {
|
if (
|
||||||
|
isAgentsEndpoint(endpointOption?.endpoint) &&
|
||||||
|
allowedProviders.size > 0 &&
|
||||||
|
!allowedProviders.has(agent.provider)
|
||||||
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`{ "type": "${ErrorTypes.INVALID_AGENT_PROVIDER}", "info": "${agent.provider}" }`,
|
`{ "type": "${ErrorTypes.INVALID_AGENT_PROVIDER}", "info": "${agent.provider}" }`,
|
||||||
);
|
);
|
||||||
@@ -82,6 +87,7 @@ const initializeAgent = async ({
|
|||||||
attachments: currentFiles,
|
attachments: currentFiles,
|
||||||
tool_resources: agent.tool_resources,
|
tool_resources: agent.tool_resources,
|
||||||
requestFileSet: new Set(requestFiles?.map((file) => file.file_id)),
|
requestFileSet: new Set(requestFiles?.map((file) => file.file_id)),
|
||||||
|
agentId: agent.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const provider = agent.provider;
|
const provider = agent.provider;
|
||||||
@@ -98,7 +104,7 @@ const initializeAgent = async ({
|
|||||||
|
|
||||||
agent.endpoint = provider;
|
agent.endpoint = provider;
|
||||||
const { getOptions, overrideProvider } = await getProviderConfig(provider);
|
const { getOptions, overrideProvider } = await getProviderConfig(provider);
|
||||||
if (overrideProvider) {
|
if (overrideProvider !== agent.provider) {
|
||||||
agent.provider = overrideProvider;
|
agent.provider = overrideProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +131,7 @@ const initializeAgent = async ({
|
|||||||
);
|
);
|
||||||
const agentMaxContextTokens = optionalChainWithEmptyCheck(
|
const agentMaxContextTokens = optionalChainWithEmptyCheck(
|
||||||
maxContextTokens,
|
maxContextTokens,
|
||||||
getModelMaxTokens(tokensModel, providerEndpointMap[provider]),
|
getModelMaxTokens(tokensModel, providerEndpointMap[provider], options.endpointTokenConfig),
|
||||||
4096,
|
4096,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -180,11 +186,12 @@ const initializeAgent = async ({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...agent,
|
...agent,
|
||||||
|
tools,
|
||||||
attachments,
|
attachments,
|
||||||
resendFiles,
|
resendFiles,
|
||||||
toolContextMap,
|
toolContextMap,
|
||||||
tools,
|
useLegacyContent: !!options.useLegacyContent,
|
||||||
maxContextTokens: (agentMaxContextTokens - maxTokens) * 0.9,
|
maxContextTokens: Math.round((agentMaxContextTokens - maxTokens) * 0.9),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const OpenAI = require('openai');
|
const OpenAI = require('openai');
|
||||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
const { ProxyAgent } = require('undici');
|
||||||
const { ErrorTypes, EModelEndpoint } = require('librechat-data-provider');
|
const { ErrorTypes, EModelEndpoint } = require('librechat-data-provider');
|
||||||
const {
|
const {
|
||||||
getUserKeyValues,
|
getUserKeyValues,
|
||||||
@@ -59,7 +59,10 @@ const initializeClient = async ({ req, res, endpointOption, version, initAppClie
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (PROXY) {
|
if (PROXY) {
|
||||||
opts.httpAgent = new HttpsProxyAgent(PROXY);
|
const proxyAgent = new ProxyAgent(PROXY);
|
||||||
|
opts.fetchOptions = {
|
||||||
|
dispatcher: proxyAgent,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (OPENAI_ORGANIZATION) {
|
if (OPENAI_ORGANIZATION) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// const OpenAI = require('openai');
|
// const OpenAI = require('openai');
|
||||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
const { ProxyAgent } = require('undici');
|
||||||
const { ErrorTypes } = require('librechat-data-provider');
|
const { ErrorTypes } = require('librechat-data-provider');
|
||||||
const { getUserKey, getUserKeyExpiry, getUserKeyValues } = require('~/server/services/UserService');
|
const { getUserKey, getUserKeyExpiry, getUserKeyValues } = require('~/server/services/UserService');
|
||||||
const initializeClient = require('./initalize');
|
const initializeClient = require('./initalize');
|
||||||
@@ -107,6 +107,7 @@ describe('initializeClient', () => {
|
|||||||
const res = {};
|
const res = {};
|
||||||
|
|
||||||
const { openai } = await initializeClient({ req, res });
|
const { openai } = await initializeClient({ req, res });
|
||||||
expect(openai.httpAgent).toBeInstanceOf(HttpsProxyAgent);
|
expect(openai.fetchOptions).toBeDefined();
|
||||||
|
expect(openai.fetchOptions.dispatcher).toBeInstanceOf(ProxyAgent);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const OpenAI = require('openai');
|
const OpenAI = require('openai');
|
||||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
const { ProxyAgent } = require('undici');
|
||||||
const { constructAzureURL, isUserProvided, resolveHeaders } = require('@librechat/api');
|
const { constructAzureURL, isUserProvided, resolveHeaders } = require('@librechat/api');
|
||||||
const { ErrorTypes, EModelEndpoint, mapModelToAzureConfig } = require('librechat-data-provider');
|
const { ErrorTypes, EModelEndpoint, mapModelToAzureConfig } = require('librechat-data-provider');
|
||||||
const {
|
const {
|
||||||
@@ -158,7 +158,10 @@ const initializeClient = async ({ req, res, version, endpointOption, initAppClie
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (PROXY) {
|
if (PROXY) {
|
||||||
opts.httpAgent = new HttpsProxyAgent(PROXY);
|
const proxyAgent = new ProxyAgent(PROXY);
|
||||||
|
opts.fetchOptions = {
|
||||||
|
dispatcher: proxyAgent,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (OPENAI_ORGANIZATION) {
|
if (OPENAI_ORGANIZATION) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// const OpenAI = require('openai');
|
// const OpenAI = require('openai');
|
||||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
const { ProxyAgent } = require('undici');
|
||||||
const { ErrorTypes } = require('librechat-data-provider');
|
const { ErrorTypes } = require('librechat-data-provider');
|
||||||
const { getUserKey, getUserKeyExpiry, getUserKeyValues } = require('~/server/services/UserService');
|
const { getUserKey, getUserKeyExpiry, getUserKeyValues } = require('~/server/services/UserService');
|
||||||
const initializeClient = require('./initialize');
|
const initializeClient = require('./initialize');
|
||||||
@@ -107,6 +107,7 @@ describe('initializeClient', () => {
|
|||||||
const res = {};
|
const res = {};
|
||||||
|
|
||||||
const { openai } = await initializeClient({ req, res });
|
const { openai } = await initializeClient({ req, res });
|
||||||
expect(openai.httpAgent).toBeInstanceOf(HttpsProxyAgent);
|
expect(openai.fetchOptions).toBeDefined();
|
||||||
|
expect(openai.fetchOptions.dispatcher).toBeInstanceOf(ProxyAgent);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -139,7 +139,11 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
|
|||||||
);
|
);
|
||||||
clientOptions.modelOptions.user = req.user.id;
|
clientOptions.modelOptions.user = req.user.id;
|
||||||
const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
|
const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
|
||||||
if (!customOptions.streamRate) {
|
if (options != null) {
|
||||||
|
options.useLegacyContent = true;
|
||||||
|
options.endpointTokenConfig = endpointTokenConfig;
|
||||||
|
}
|
||||||
|
if (!clientOptions.streamRate) {
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
options.llmConfig.callbacks = [
|
options.llmConfig.callbacks = [
|
||||||
@@ -156,6 +160,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
useLegacyContent: true,
|
||||||
llmConfig: modelOptions,
|
llmConfig: modelOptions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
|
|||||||
/** Only attempt to load service key if GOOGLE_KEY is not provided */
|
/** Only attempt to load service key if GOOGLE_KEY is not provided */
|
||||||
try {
|
try {
|
||||||
const serviceKeyPath =
|
const serviceKeyPath =
|
||||||
process.env.GOOGLE_SERVICE_KEY_FILE_PATH ||
|
process.env.GOOGLE_SERVICE_KEY_FILE ||
|
||||||
path.join(__dirname, '../../../..', 'data', 'auth.json');
|
path.join(__dirname, '../../../..', 'data', 'auth.json');
|
||||||
serviceKey = await loadServiceKey(serviceKeyPath);
|
serviceKey = await loadServiceKey(serviceKeyPath);
|
||||||
if (!serviceKey) {
|
if (!serviceKey) {
|
||||||
|
|||||||
@@ -34,13 +34,13 @@ const providerConfigMap = {
|
|||||||
* @param {string} provider - The provider string
|
* @param {string} provider - The provider string
|
||||||
* @returns {Promise<{
|
* @returns {Promise<{
|
||||||
* getOptions: Function,
|
* getOptions: Function,
|
||||||
* overrideProvider?: string,
|
* overrideProvider: string,
|
||||||
* customEndpointConfig?: TEndpoint
|
* customEndpointConfig?: TEndpoint
|
||||||
* }>}
|
* }>}
|
||||||
*/
|
*/
|
||||||
async function getProviderConfig(provider) {
|
async function getProviderConfig(provider) {
|
||||||
let getOptions = providerConfigMap[provider];
|
let getOptions = providerConfigMap[provider];
|
||||||
let overrideProvider;
|
let overrideProvider = provider;
|
||||||
/** @type {TEndpoint | undefined} */
|
/** @type {TEndpoint | undefined} */
|
||||||
let customEndpointConfig;
|
let customEndpointConfig;
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ async function getProviderConfig(provider) {
|
|||||||
overrideProvider = Providers.OPENAI;
|
overrideProvider = Providers.OPENAI;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isKnownCustomProvider(overrideProvider || provider) && !customEndpointConfig) {
|
if (isKnownCustomProvider(overrideProvider) && !customEndpointConfig) {
|
||||||
customEndpointConfig = await getCustomEndpointConfig(provider);
|
customEndpointConfig = await getCustomEndpointConfig(provider);
|
||||||
if (!customEndpointConfig) {
|
if (!customEndpointConfig) {
|
||||||
throw new Error(`Provider ${provider} not supported`);
|
throw new Error(`Provider ${provider} not supported`);
|
||||||
|
|||||||
@@ -65,19 +65,20 @@ const initializeClient = async ({
|
|||||||
const isAzureOpenAI = endpoint === EModelEndpoint.azureOpenAI;
|
const isAzureOpenAI = endpoint === EModelEndpoint.azureOpenAI;
|
||||||
/** @type {false | TAzureConfig} */
|
/** @type {false | TAzureConfig} */
|
||||||
const azureConfig = isAzureOpenAI && req.app.locals[EModelEndpoint.azureOpenAI];
|
const azureConfig = isAzureOpenAI && req.app.locals[EModelEndpoint.azureOpenAI];
|
||||||
|
let serverless = false;
|
||||||
if (isAzureOpenAI && azureConfig) {
|
if (isAzureOpenAI && azureConfig) {
|
||||||
const { modelGroupMap, groupMap } = azureConfig;
|
const { modelGroupMap, groupMap } = azureConfig;
|
||||||
const {
|
const {
|
||||||
azureOptions,
|
azureOptions,
|
||||||
baseURL,
|
baseURL,
|
||||||
headers = {},
|
headers = {},
|
||||||
serverless,
|
serverless: _serverless,
|
||||||
} = mapModelToAzureConfig({
|
} = mapModelToAzureConfig({
|
||||||
modelName,
|
modelName,
|
||||||
modelGroupMap,
|
modelGroupMap,
|
||||||
groupMap,
|
groupMap,
|
||||||
});
|
});
|
||||||
|
serverless = _serverless;
|
||||||
|
|
||||||
clientOptions.reverseProxyUrl = baseURL ?? clientOptions.reverseProxyUrl;
|
clientOptions.reverseProxyUrl = baseURL ?? clientOptions.reverseProxyUrl;
|
||||||
clientOptions.headers = resolveHeaders(
|
clientOptions.headers = resolveHeaders(
|
||||||
@@ -143,6 +144,9 @@ const initializeClient = async ({
|
|||||||
clientOptions = Object.assign({ modelOptions }, clientOptions);
|
clientOptions = Object.assign({ modelOptions }, clientOptions);
|
||||||
clientOptions.modelOptions.user = req.user.id;
|
clientOptions.modelOptions.user = req.user.id;
|
||||||
const options = getOpenAIConfig(apiKey, clientOptions);
|
const options = getOpenAIConfig(apiKey, clientOptions);
|
||||||
|
if (options != null && serverless === true) {
|
||||||
|
options.useLegacyContent = true;
|
||||||
|
}
|
||||||
const streamRate = clientOptions.streamRate;
|
const streamRate = clientOptions.streamRate;
|
||||||
if (!streamRate) {
|
if (!streamRate) {
|
||||||
return options;
|
return options;
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ async function getSessionInfo(fileIdentifier, apiKey) {
|
|||||||
* @param {Object} options
|
* @param {Object} options
|
||||||
* @param {ServerRequest} options.req
|
* @param {ServerRequest} options.req
|
||||||
* @param {Agent['tool_resources']} options.tool_resources
|
* @param {Agent['tool_resources']} options.tool_resources
|
||||||
|
* @param {string} [options.agentId] - The agent ID for file access control
|
||||||
* @param {string} apiKey
|
* @param {string} apiKey
|
||||||
* @returns {Promise<{
|
* @returns {Promise<{
|
||||||
* files: Array<{ id: string; session_id: string; name: string }>,
|
* files: Array<{ id: string; session_id: string; name: string }>,
|
||||||
@@ -159,11 +160,18 @@ async function getSessionInfo(fileIdentifier, apiKey) {
|
|||||||
* }>}
|
* }>}
|
||||||
*/
|
*/
|
||||||
const primeFiles = async (options, apiKey) => {
|
const primeFiles = async (options, apiKey) => {
|
||||||
const { tool_resources } = options;
|
const { tool_resources, req, agentId } = options;
|
||||||
const file_ids = tool_resources?.[EToolResources.execute_code]?.file_ids ?? [];
|
const file_ids = tool_resources?.[EToolResources.execute_code]?.file_ids ?? [];
|
||||||
const agentResourceIds = new Set(file_ids);
|
const agentResourceIds = new Set(file_ids);
|
||||||
const resourceFiles = tool_resources?.[EToolResources.execute_code]?.files ?? [];
|
const resourceFiles = tool_resources?.[EToolResources.execute_code]?.files ?? [];
|
||||||
const dbFiles = ((await getFiles({ file_id: { $in: file_ids } })) ?? []).concat(resourceFiles);
|
const dbFiles = (
|
||||||
|
(await getFiles(
|
||||||
|
{ file_id: { $in: file_ids } },
|
||||||
|
null,
|
||||||
|
{ text: 0 },
|
||||||
|
{ userId: req?.user?.id, agentId },
|
||||||
|
)) ?? []
|
||||||
|
).concat(resourceFiles);
|
||||||
|
|
||||||
const files = [];
|
const files = [];
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
|
|||||||
@@ -2,16 +2,16 @@ const { z } = require('zod');
|
|||||||
const { tool } = require('@langchain/core/tools');
|
const { tool } = require('@langchain/core/tools');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { Time, CacheKeys, StepTypes } = require('librechat-data-provider');
|
const { Time, CacheKeys, StepTypes } = require('librechat-data-provider');
|
||||||
const { sendEvent, normalizeServerName, MCPOAuthHandler } = require('@librechat/api');
|
|
||||||
const { Constants: AgentConstants, Providers, GraphEvents } = require('@librechat/agents');
|
const { Constants: AgentConstants, Providers, GraphEvents } = require('@librechat/agents');
|
||||||
|
const { Constants, ContentTypes, isAssistantsEndpoint } = require('librechat-data-provider');
|
||||||
const {
|
const {
|
||||||
Constants,
|
sendEvent,
|
||||||
ContentTypes,
|
MCPOAuthHandler,
|
||||||
isAssistantsEndpoint,
|
normalizeServerName,
|
||||||
convertJsonSchemaToZod,
|
convertWithResolvedRefs,
|
||||||
} = require('librechat-data-provider');
|
} = require('@librechat/api');
|
||||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
|
||||||
const { findToken, createToken, updateToken } = require('~/models');
|
const { findToken, createToken, updateToken } = require('~/models');
|
||||||
|
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||||
const { getCachedTools } = require('./Config');
|
const { getCachedTools } = require('./Config');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ function createAbortHandler({ userId, serverName, toolName, flowManager }) {
|
|||||||
* @returns { Promise<typeof tool | { _call: (toolInput: Object | string) => unknown}> } An object with `_call` method to execute the tool input.
|
* @returns { Promise<typeof tool | { _call: (toolInput: Object | string) => unknown}> } An object with `_call` method to execute the tool input.
|
||||||
*/
|
*/
|
||||||
async function createMCPTool({ req, res, toolKey, provider: _provider }) {
|
async function createMCPTool({ req, res, toolKey, provider: _provider }) {
|
||||||
const availableTools = await getCachedTools({ includeGlobal: true });
|
const availableTools = await getCachedTools({ userId: req.user?.id, includeGlobal: true });
|
||||||
const toolDefinition = availableTools?.[toolKey]?.function;
|
const toolDefinition = availableTools?.[toolKey]?.function;
|
||||||
if (!toolDefinition) {
|
if (!toolDefinition) {
|
||||||
logger.error(`Tool ${toolKey} not found in available tools`);
|
logger.error(`Tool ${toolKey} not found in available tools`);
|
||||||
@@ -113,7 +113,7 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) {
|
|||||||
/** @type {LCTool} */
|
/** @type {LCTool} */
|
||||||
const { description, parameters } = toolDefinition;
|
const { description, parameters } = toolDefinition;
|
||||||
const isGoogle = _provider === Providers.VERTEXAI || _provider === Providers.GOOGLE;
|
const isGoogle = _provider === Providers.VERTEXAI || _provider === Providers.GOOGLE;
|
||||||
let schema = convertJsonSchemaToZod(parameters, {
|
let schema = convertWithResolvedRefs(parameters, {
|
||||||
allowEmptyObject: !isGoogle,
|
allowEmptyObject: !isGoogle,
|
||||||
transformOneOfAnyOf: true,
|
transformOneOfAnyOf: true,
|
||||||
});
|
});
|
||||||
@@ -235,6 +235,7 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) {
|
|||||||
responseFormat: AgentConstants.CONTENT_AND_ARTIFACT,
|
responseFormat: AgentConstants.CONTENT_AND_ARTIFACT,
|
||||||
});
|
});
|
||||||
toolInstance.mcp = true;
|
toolInstance.mcp = true;
|
||||||
|
toolInstance.mcpRawServerName = serverName;
|
||||||
return toolInstance;
|
return toolInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const { findOnePluginAuth, updatePluginAuth, deletePluginAuth } = require('~/mod
|
|||||||
* @param {string} userId - The unique identifier of the user for whom the plugin authentication value is to be retrieved.
|
* @param {string} userId - The unique identifier of the user for whom the plugin authentication value is to be retrieved.
|
||||||
* @param {string} authField - The specific authentication field (e.g., 'API_KEY', 'URL') whose value is to be retrieved and decrypted.
|
* @param {string} authField - The specific authentication field (e.g., 'API_KEY', 'URL') whose value is to be retrieved and decrypted.
|
||||||
* @param {boolean} throwError - Whether to throw an error if the authentication value does not exist. Defaults to `true`.
|
* @param {boolean} throwError - Whether to throw an error if the authentication value does not exist. Defaults to `true`.
|
||||||
|
* @param {string} [pluginKey] - Optional plugin key to make the lookup more specific to a particular plugin.
|
||||||
* @returns {Promise<string|null>} A promise that resolves to the decrypted authentication value if found, or `null` if no such authentication value exists for the given user and field.
|
* @returns {Promise<string|null>} A promise that resolves to the decrypted authentication value if found, or `null` if no such authentication value exists for the given user and field.
|
||||||
*
|
*
|
||||||
* The function throws an error if it encounters any issue during the retrieval or decryption process, or if the authentication value does not exist.
|
* The function throws an error if it encounters any issue during the retrieval or decryption process, or if the authentication value does not exist.
|
||||||
@@ -20,14 +21,28 @@ const { findOnePluginAuth, updatePluginAuth, deletePluginAuth } = require('~/mod
|
|||||||
* console.error(err);
|
* console.error(err);
|
||||||
* });
|
* });
|
||||||
*
|
*
|
||||||
|
* @example
|
||||||
|
* // To get the decrypted value of the 'API_KEY' field for a specific plugin:
|
||||||
|
* getUserPluginAuthValue('12345', 'API_KEY', true, 'mcp-server-name').then(value => {
|
||||||
|
* console.log(value);
|
||||||
|
* }).catch(err => {
|
||||||
|
* console.error(err);
|
||||||
|
* });
|
||||||
|
*
|
||||||
* @throws {Error} Throws an error if there's an issue during the retrieval or decryption process, or if the authentication value does not exist.
|
* @throws {Error} Throws an error if there's an issue during the retrieval or decryption process, or if the authentication value does not exist.
|
||||||
* @async
|
* @async
|
||||||
*/
|
*/
|
||||||
const getUserPluginAuthValue = async (userId, authField, throwError = true) => {
|
const getUserPluginAuthValue = async (userId, authField, throwError = true, pluginKey) => {
|
||||||
try {
|
try {
|
||||||
const pluginAuth = await findOnePluginAuth({ userId, authField });
|
const searchParams = { userId, authField };
|
||||||
|
if (pluginKey) {
|
||||||
|
searchParams.pluginKey = pluginKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginAuth = await findOnePluginAuth(searchParams);
|
||||||
if (!pluginAuth) {
|
if (!pluginAuth) {
|
||||||
throw new Error(`No plugin auth ${authField} found for user ${userId}`);
|
const pluginInfo = pluginKey ? ` for plugin ${pluginKey}` : '';
|
||||||
|
throw new Error(`No plugin auth ${authField} found for user ${userId}${pluginInfo}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const decryptedValue = await decrypt(pluginAuth.value);
|
const decryptedValue = await decrypt(pluginAuth.value);
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ async function processRequiredActions(client, requiredActions) {
|
|||||||
`[required actions] user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`,
|
`[required actions] user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`,
|
||||||
requiredActions,
|
requiredActions,
|
||||||
);
|
);
|
||||||
const toolDefinitions = await getCachedTools({ includeGlobal: true });
|
const toolDefinitions = await getCachedTools({ userId: client.req.user.id, includeGlobal: true });
|
||||||
const seenToolkits = new Set();
|
const seenToolkits = new Set();
|
||||||
const tools = requiredActions
|
const tools = requiredActions
|
||||||
.map((action) => {
|
.map((action) => {
|
||||||
|
|||||||
@@ -9,20 +9,35 @@ const { getLogStores } = require('~/cache');
|
|||||||
* Initialize MCP servers
|
* Initialize MCP servers
|
||||||
* @param {import('express').Application} app - Express app instance
|
* @param {import('express').Application} app - Express app instance
|
||||||
*/
|
*/
|
||||||
async function initializeMCP(app) {
|
async function initializeMCPs(app) {
|
||||||
const mcpServers = app.locals.mcpConfig;
|
const mcpServers = app.locals.mcpConfig;
|
||||||
if (!mcpServers) {
|
if (!mcpServers) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter out servers with startup: false
|
||||||
|
const filteredServers = {};
|
||||||
|
for (const [name, config] of Object.entries(mcpServers)) {
|
||||||
|
if (config.startup === false) {
|
||||||
|
logger.info(`Skipping MCP server '${name}' due to startup: false`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
filteredServers[name] = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(filteredServers).length === 0) {
|
||||||
|
logger.info('[MCP] No MCP servers to initialize (all skipped or none configured)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('Initializing MCP servers...');
|
logger.info('Initializing MCP servers...');
|
||||||
const mcpManager = getMCPManager();
|
const mcpManager = getMCPManager();
|
||||||
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||||
const flowManager = flowsCache ? getFlowStateManager(flowsCache) : null;
|
const flowManager = flowsCache ? getFlowStateManager(flowsCache) : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await mcpManager.initializeMCP({
|
await mcpManager.initializeMCPs({
|
||||||
mcpServers,
|
mcpServers: filteredServers,
|
||||||
flowManager,
|
flowManager,
|
||||||
tokenMethods: {
|
tokenMethods: {
|
||||||
findToken,
|
findToken,
|
||||||
@@ -44,10 +59,14 @@ async function initializeMCP(app) {
|
|||||||
await mcpManager.mapAvailableTools(toolsCopy, flowManager);
|
await mcpManager.mapAvailableTools(toolsCopy, flowManager);
|
||||||
await setCachedTools(toolsCopy, { isGlobal: true });
|
await setCachedTools(toolsCopy, { isGlobal: true });
|
||||||
|
|
||||||
|
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||||
|
await cache.delete(CacheKeys.TOOLS);
|
||||||
|
logger.debug('Cleared tools array cache after MCP initialization');
|
||||||
|
|
||||||
logger.info('MCP servers initialized successfully');
|
logger.info('MCP servers initialized successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to initialize MCP servers:', error);
|
logger.error('Failed to initialize MCP servers:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = initializeMCP;
|
module.exports = initializeMCPs;
|
||||||
@@ -52,6 +52,11 @@ function assistantsConfigSetup(config, assistantsEndpoint, prevConfig = {}) {
|
|||||||
privateAssistants: parsedConfig.privateAssistants,
|
privateAssistants: parsedConfig.privateAssistants,
|
||||||
timeoutMs: parsedConfig.timeoutMs,
|
timeoutMs: parsedConfig.timeoutMs,
|
||||||
streamRate: parsedConfig.streamRate,
|
streamRate: parsedConfig.streamRate,
|
||||||
|
titlePrompt: parsedConfig.titlePrompt,
|
||||||
|
titleMethod: parsedConfig.titleMethod,
|
||||||
|
titleModel: parsedConfig.titleModel,
|
||||||
|
titleEndpoint: parsedConfig.titleEndpoint,
|
||||||
|
titlePromptTemplate: parsedConfig.titlePromptTemplate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
const { webSearchKeys } = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
Constants,
|
Constants,
|
||||||
webSearchKeys,
|
|
||||||
deprecatedAzureVariables,
|
deprecatedAzureVariables,
|
||||||
conflictingAzureVariables,
|
conflictingAzureVariables,
|
||||||
extractVariableName,
|
extractVariableName,
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
|
|||||||
temporaryChat: interfaceConfig?.temporaryChat ?? defaults.temporaryChat,
|
temporaryChat: interfaceConfig?.temporaryChat ?? defaults.temporaryChat,
|
||||||
runCode: interfaceConfig?.runCode ?? defaults.runCode,
|
runCode: interfaceConfig?.runCode ?? defaults.runCode,
|
||||||
webSearch: interfaceConfig?.webSearch ?? defaults.webSearch,
|
webSearch: interfaceConfig?.webSearch ?? defaults.webSearch,
|
||||||
|
fileSearch: interfaceConfig?.fileSearch ?? defaults.fileSearch,
|
||||||
customWelcome: interfaceConfig?.customWelcome ?? defaults.customWelcome,
|
customWelcome: interfaceConfig?.customWelcome ?? defaults.customWelcome,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
|
|||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch },
|
||||||
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch },
|
||||||
});
|
});
|
||||||
await updateAccessPermissions(SystemRoles.ADMIN, {
|
await updateAccessPermissions(SystemRoles.ADMIN, {
|
||||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: loadedInterface.prompts },
|
[PermissionTypes.PROMPTS]: { [Permissions.USE]: loadedInterface.prompts },
|
||||||
@@ -78,6 +80,7 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
|
|||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch },
|
||||||
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch },
|
||||||
});
|
});
|
||||||
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ describe('loadDefaultInterface', () => {
|
|||||||
temporaryChat: true,
|
temporaryChat: true,
|
||||||
runCode: true,
|
runCode: true,
|
||||||
webSearch: true,
|
webSearch: true,
|
||||||
|
fileSearch: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const configDefaults = { interface: {} };
|
const configDefaults = { interface: {} };
|
||||||
@@ -27,12 +28,13 @@ describe('loadDefaultInterface', () => {
|
|||||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true },
|
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
|
||||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
|
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||||
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,6 +49,7 @@ describe('loadDefaultInterface', () => {
|
|||||||
temporaryChat: false,
|
temporaryChat: false,
|
||||||
runCode: false,
|
runCode: false,
|
||||||
webSearch: false,
|
webSearch: false,
|
||||||
|
fileSearch: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const configDefaults = { interface: {} };
|
const configDefaults = { interface: {} };
|
||||||
@@ -56,12 +59,13 @@ describe('loadDefaultInterface', () => {
|
|||||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: false },
|
[PermissionTypes.PROMPTS]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: false },
|
[PermissionTypes.MEMORIES]: { [Permissions.USE]: false, [Permissions.OPT_OUT]: undefined },
|
||||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false },
|
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: false },
|
[PermissionTypes.AGENTS]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: false },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: false },
|
||||||
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,12 +78,16 @@ describe('loadDefaultInterface', () => {
|
|||||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined },
|
[PermissionTypes.MEMORIES]: {
|
||||||
|
[Permissions.USE]: undefined,
|
||||||
|
[Permissions.OPT_OUT]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,6 +102,7 @@ describe('loadDefaultInterface', () => {
|
|||||||
temporaryChat: undefined,
|
temporaryChat: undefined,
|
||||||
runCode: undefined,
|
runCode: undefined,
|
||||||
webSearch: undefined,
|
webSearch: undefined,
|
||||||
|
fileSearch: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const configDefaults = { interface: {} };
|
const configDefaults = { interface: {} };
|
||||||
@@ -103,12 +112,16 @@ describe('loadDefaultInterface', () => {
|
|||||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined },
|
[PermissionTypes.MEMORIES]: {
|
||||||
|
[Permissions.USE]: undefined,
|
||||||
|
[Permissions.OPT_OUT]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,6 +136,7 @@ describe('loadDefaultInterface', () => {
|
|||||||
temporaryChat: undefined,
|
temporaryChat: undefined,
|
||||||
runCode: false,
|
runCode: false,
|
||||||
webSearch: true,
|
webSearch: true,
|
||||||
|
fileSearch: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const configDefaults = { interface: {} };
|
const configDefaults = { interface: {} };
|
||||||
@@ -132,12 +146,13 @@ describe('loadDefaultInterface', () => {
|
|||||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true },
|
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
|
||||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
|
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||||
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,6 +168,7 @@ describe('loadDefaultInterface', () => {
|
|||||||
temporaryChat: true,
|
temporaryChat: true,
|
||||||
runCode: true,
|
runCode: true,
|
||||||
webSearch: true,
|
webSearch: true,
|
||||||
|
fileSearch: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -161,12 +177,13 @@ describe('loadDefaultInterface', () => {
|
|||||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true },
|
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
|
||||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
|
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||||
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,12 +196,16 @@ describe('loadDefaultInterface', () => {
|
|||||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined },
|
[PermissionTypes.MEMORIES]: {
|
||||||
|
[Permissions.USE]: undefined,
|
||||||
|
[Permissions.OPT_OUT]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -197,12 +218,16 @@ describe('loadDefaultInterface', () => {
|
|||||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined },
|
[PermissionTypes.MEMORIES]: {
|
||||||
|
[Permissions.USE]: undefined,
|
||||||
|
[Permissions.OPT_OUT]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false },
|
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,12 +240,16 @@ describe('loadDefaultInterface', () => {
|
|||||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined },
|
[PermissionTypes.MEMORIES]: {
|
||||||
|
[Permissions.USE]: undefined,
|
||||||
|
[Permissions.OPT_OUT]: undefined,
|
||||||
|
},
|
||||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -234,6 +263,7 @@ describe('loadDefaultInterface', () => {
|
|||||||
agents: false,
|
agents: false,
|
||||||
temporaryChat: true,
|
temporaryChat: true,
|
||||||
runCode: false,
|
runCode: false,
|
||||||
|
fileSearch: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const configDefaults = { interface: {} };
|
const configDefaults = { interface: {} };
|
||||||
@@ -243,12 +273,13 @@ describe('loadDefaultInterface', () => {
|
|||||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true },
|
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
|
||||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: false },
|
[PermissionTypes.AGENTS]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -264,6 +295,7 @@ describe('loadDefaultInterface', () => {
|
|||||||
temporaryChat: undefined,
|
temporaryChat: undefined,
|
||||||
runCode: undefined,
|
runCode: undefined,
|
||||||
webSearch: undefined,
|
webSearch: undefined,
|
||||||
|
fileSearch: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -272,12 +304,13 @@ describe('loadDefaultInterface', () => {
|
|||||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: false },
|
[PermissionTypes.MEMORIES]: { [Permissions.USE]: false, [Permissions.OPT_OUT]: undefined },
|
||||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false },
|
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -300,12 +333,90 @@ describe('loadDefaultInterface', () => {
|
|||||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true },
|
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
|
||||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: false },
|
[PermissionTypes.AGENTS]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call updateAccessPermissions with the correct parameters when FILE_SEARCH is true', async () => {
|
||||||
|
const config = {
|
||||||
|
interface: {
|
||||||
|
fileSearch: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const configDefaults = { interface: {} };
|
||||||
|
|
||||||
|
await loadDefaultInterface(config, configDefaults);
|
||||||
|
|
||||||
|
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||||
|
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call updateAccessPermissions with false when FILE_SEARCH is false', async () => {
|
||||||
|
const config = {
|
||||||
|
interface: {
|
||||||
|
fileSearch: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const configDefaults = { interface: {} };
|
||||||
|
|
||||||
|
await loadDefaultInterface(config, configDefaults);
|
||||||
|
|
||||||
|
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||||
|
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||||
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call updateAccessPermissions with all interface options including fileSearch', async () => {
|
||||||
|
const config = {
|
||||||
|
interface: {
|
||||||
|
prompts: true,
|
||||||
|
bookmarks: false,
|
||||||
|
memories: true,
|
||||||
|
multiConvo: true,
|
||||||
|
agents: false,
|
||||||
|
temporaryChat: true,
|
||||||
|
runCode: false,
|
||||||
|
webSearch: true,
|
||||||
|
fileSearch: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const configDefaults = { interface: {} };
|
||||||
|
|
||||||
|
await loadDefaultInterface(config, configDefaults);
|
||||||
|
|
||||||
|
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||||
|
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||||
|
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||||
|
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
|
||||||
|
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||||
|
[PermissionTypes.AGENTS]: { [Permissions.USE]: false },
|
||||||
|
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||||
|
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||||
|
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||||
|
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
const { Keyv } = require('keyv');
|
|
||||||
const passport = require('passport');
|
const passport = require('passport');
|
||||||
const session = require('express-session');
|
const session = require('express-session');
|
||||||
const MemoryStore = require('memorystore')(session);
|
|
||||||
const RedisStore = require('connect-redis').default;
|
|
||||||
const {
|
const {
|
||||||
setupOpenId,
|
setupOpenId,
|
||||||
googleLogin,
|
googleLogin,
|
||||||
@@ -14,8 +11,9 @@ const {
|
|||||||
openIdJwtLogin,
|
openIdJwtLogin,
|
||||||
} = require('~/strategies');
|
} = require('~/strategies');
|
||||||
const { isEnabled } = require('~/server/utils');
|
const { isEnabled } = require('~/server/utils');
|
||||||
const keyvRedis = require('~/cache/keyvRedis');
|
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
const { getLogStores } = require('~/cache');
|
||||||
|
const { CacheKeys } = require('librechat-data-provider');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@@ -51,17 +49,8 @@ const configureSocialLogins = async (app) => {
|
|||||||
secret: process.env.OPENID_SESSION_SECRET,
|
secret: process.env.OPENID_SESSION_SECRET,
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
|
store: getLogStores(CacheKeys.OPENID_SESSION),
|
||||||
};
|
};
|
||||||
if (isEnabled(process.env.USE_REDIS)) {
|
|
||||||
logger.debug('Using Redis for session storage in OpenID...');
|
|
||||||
const keyv = new Keyv({ store: keyvRedis });
|
|
||||||
const client = keyv.opts.store.client;
|
|
||||||
sessionOptions.store = new RedisStore({ client, prefix: 'openid_session' });
|
|
||||||
} else {
|
|
||||||
sessionOptions.store = new MemoryStore({
|
|
||||||
checkPeriod: 86400000, // prune expired entries every 24h
|
|
||||||
});
|
|
||||||
}
|
|
||||||
app.use(session(sessionOptions));
|
app.use(session(sessionOptions));
|
||||||
app.use(passport.session());
|
app.use(passport.session());
|
||||||
const config = await setupOpenId();
|
const config = await setupOpenId();
|
||||||
@@ -82,17 +71,8 @@ const configureSocialLogins = async (app) => {
|
|||||||
secret: process.env.SAML_SESSION_SECRET,
|
secret: process.env.SAML_SESSION_SECRET,
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
|
store: getLogStores(CacheKeys.SAML_SESSION),
|
||||||
};
|
};
|
||||||
if (isEnabled(process.env.USE_REDIS)) {
|
|
||||||
logger.debug('Using Redis for session storage in SAML...');
|
|
||||||
const keyv = new Keyv({ store: keyvRedis });
|
|
||||||
const client = keyv.opts.store.client;
|
|
||||||
sessionOptions.store = new RedisStore({ client, prefix: 'saml_session' });
|
|
||||||
} else {
|
|
||||||
sessionOptions.store = new MemoryStore({
|
|
||||||
checkPeriod: 86400000, // prune expired entries every 24h
|
|
||||||
});
|
|
||||||
}
|
|
||||||
app.use(session(sessionOptions));
|
app.use(session(sessionOptions));
|
||||||
app.use(passport.session());
|
app.use(passport.session());
|
||||||
setupSaml();
|
setupSaml();
|
||||||
|
|||||||
407
api/server/utils/__tests__/staticCache.spec.js
Normal file
407
api/server/utils/__tests__/staticCache.spec.js
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
const zlib = require('zlib');
|
||||||
|
const staticCache = require('../staticCache');
|
||||||
|
|
||||||
|
describe('staticCache', () => {
|
||||||
|
let app;
|
||||||
|
let testDir;
|
||||||
|
let testFile;
|
||||||
|
let indexFile;
|
||||||
|
let manifestFile;
|
||||||
|
let swFile;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// Create a test directory and files
|
||||||
|
testDir = path.join(__dirname, 'test-static');
|
||||||
|
if (!fs.existsSync(testDir)) {
|
||||||
|
fs.mkdirSync(testDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test files
|
||||||
|
testFile = path.join(testDir, 'test.js');
|
||||||
|
indexFile = path.join(testDir, 'index.html');
|
||||||
|
manifestFile = path.join(testDir, 'manifest.json');
|
||||||
|
swFile = path.join(testDir, 'sw.js');
|
||||||
|
|
||||||
|
const jsContent = 'console.log("test");';
|
||||||
|
const htmlContent = '<html><body>Test</body></html>';
|
||||||
|
const jsonContent = '{"name": "test"}';
|
||||||
|
const swContent = 'self.addEventListener("install", () => {});';
|
||||||
|
|
||||||
|
fs.writeFileSync(testFile, jsContent);
|
||||||
|
fs.writeFileSync(indexFile, htmlContent);
|
||||||
|
fs.writeFileSync(manifestFile, jsonContent);
|
||||||
|
fs.writeFileSync(swFile, swContent);
|
||||||
|
|
||||||
|
// Create gzipped versions of some files
|
||||||
|
fs.writeFileSync(testFile + '.gz', zlib.gzipSync(jsContent));
|
||||||
|
fs.writeFileSync(path.join(testDir, 'test.css'), 'body { color: red; }');
|
||||||
|
fs.writeFileSync(path.join(testDir, 'test.css.gz'), zlib.gzipSync('body { color: red; }'));
|
||||||
|
|
||||||
|
// Create a file that only exists in gzipped form
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(testDir, 'only-gzipped.js.gz'),
|
||||||
|
zlib.gzipSync('console.log("only gzipped");'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a subdirectory for dist/images testing
|
||||||
|
const distImagesDir = path.join(testDir, 'dist', 'images');
|
||||||
|
fs.mkdirSync(distImagesDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(distImagesDir, 'logo.png'), 'fake-png-data');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
// Clean up test files
|
||||||
|
if (fs.existsSync(testDir)) {
|
||||||
|
fs.rmSync(testDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = express();
|
||||||
|
|
||||||
|
// Clear environment variables
|
||||||
|
delete process.env.NODE_ENV;
|
||||||
|
delete process.env.STATIC_CACHE_S_MAX_AGE;
|
||||||
|
delete process.env.STATIC_CACHE_MAX_AGE;
|
||||||
|
});
|
||||||
|
describe('cache headers in production', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set standard cache headers for regular files', async () => {
|
||||||
|
app.use(staticCache(testDir));
|
||||||
|
|
||||||
|
const response = await request(app).get('/test.js').expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set no-cache headers for index.html', async () => {
|
||||||
|
app.use(staticCache(testDir));
|
||||||
|
|
||||||
|
const response = await request(app).get('/index.html').expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set no-cache headers for manifest.json', async () => {
|
||||||
|
app.use(staticCache(testDir));
|
||||||
|
|
||||||
|
const response = await request(app).get('/manifest.json').expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set no-cache headers for sw.js', async () => {
|
||||||
|
app.use(staticCache(testDir));
|
||||||
|
|
||||||
|
const response = await request(app).get('/sw.js').expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not set cache headers for /dist/images/ files', async () => {
|
||||||
|
app.use(staticCache(testDir));
|
||||||
|
|
||||||
|
const response = await request(app).get('/dist/images/logo.png').expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['cache-control']).toBe('public, max-age=0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set no-cache headers when noCache option is true', async () => {
|
||||||
|
app.use(staticCache(testDir, { noCache: true }));
|
||||||
|
|
||||||
|
const response = await request(app).get('/test.js').expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cache headers in non-production', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.NODE_ENV = 'development';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not set cache headers in development', async () => {
|
||||||
|
app.use(staticCache(testDir));
|
||||||
|
|
||||||
|
const response = await request(app).get('/test.js').expect(200);
|
||||||
|
|
||||||
|
// Our middleware should not set cache-control in non-production
|
||||||
|
// Express static might set its own default headers
|
||||||
|
const cacheControl = response.headers['cache-control'];
|
||||||
|
expect(cacheControl).toBe('public, max-age=0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('environment variable configuration', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom s-maxage from environment', async () => {
|
||||||
|
process.env.STATIC_CACHE_S_MAX_AGE = '3600';
|
||||||
|
|
||||||
|
// Need to re-require to pick up new env vars
|
||||||
|
jest.resetModules();
|
||||||
|
const freshStaticCache = require('../staticCache');
|
||||||
|
|
||||||
|
app.use(freshStaticCache(testDir));
|
||||||
|
|
||||||
|
const response = await request(app).get('/test.js').expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=3600');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom max-age from environment', async () => {
|
||||||
|
process.env.STATIC_CACHE_MAX_AGE = '7200';
|
||||||
|
|
||||||
|
// Need to re-require to pick up new env vars
|
||||||
|
jest.resetModules();
|
||||||
|
const freshStaticCache = require('../staticCache');
|
||||||
|
|
||||||
|
app.use(freshStaticCache(testDir));
|
||||||
|
|
||||||
|
const response = await request(app).get('/test.js').expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['cache-control']).toBe('public, max-age=7200, s-maxage=86400');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use both custom values from environment', async () => {
|
||||||
|
process.env.STATIC_CACHE_S_MAX_AGE = '1800';
|
||||||
|
process.env.STATIC_CACHE_MAX_AGE = '3600';
|
||||||
|
|
||||||
|
// Need to re-require to pick up new env vars
|
||||||
|
jest.resetModules();
|
||||||
|
const freshStaticCache = require('../staticCache');
|
||||||
|
|
||||||
|
app.use(freshStaticCache(testDir));
|
||||||
|
|
||||||
|
const response = await request(app).get('/test.js').expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['cache-control']).toBe('public, max-age=3600, s-maxage=1800');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('express-static-gzip behavior', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should serve gzipped files when client accepts gzip encoding', async () => {
|
||||||
|
app.use(staticCache(testDir, { skipGzipScan: false }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/test.js')
|
||||||
|
.set('Accept-Encoding', 'gzip, deflate')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['content-encoding']).toBe('gzip');
|
||||||
|
expect(response.headers['content-type']).toMatch(/javascript/);
|
||||||
|
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
|
||||||
|
// Content should be decompressed by supertest
|
||||||
|
expect(response.text).toBe('console.log("test");');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to uncompressed files when client does not accept gzip', async () => {
|
||||||
|
app.use(staticCache(testDir, { skipGzipScan: false }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/test.js')
|
||||||
|
.set('Accept-Encoding', 'identity')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['content-encoding']).toBeUndefined();
|
||||||
|
expect(response.headers['content-type']).toMatch(/javascript/);
|
||||||
|
expect(response.text).toBe('console.log("test");');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should serve gzipped CSS files with correct content-type', async () => {
|
||||||
|
app.use(staticCache(testDir, { skipGzipScan: false }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/test.css')
|
||||||
|
.set('Accept-Encoding', 'gzip')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['content-encoding']).toBe('gzip');
|
||||||
|
expect(response.headers['content-type']).toMatch(/css/);
|
||||||
|
expect(response.text).toBe('body { color: red; }');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should serve uncompressed files when no gzipped version exists', async () => {
|
||||||
|
app.use(staticCache(testDir, { skipGzipScan: false }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/manifest.json')
|
||||||
|
.set('Accept-Encoding', 'gzip')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['content-encoding']).toBeUndefined();
|
||||||
|
expect(response.headers['content-type']).toMatch(/json/);
|
||||||
|
expect(response.text).toBe('{"name": "test"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle files that only exist in gzipped form', async () => {
|
||||||
|
app.use(staticCache(testDir, { skipGzipScan: false }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/only-gzipped.js')
|
||||||
|
.set('Accept-Encoding', 'gzip')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['content-encoding']).toBe('gzip');
|
||||||
|
expect(response.headers['content-type']).toMatch(/javascript/);
|
||||||
|
expect(response.text).toBe('console.log("only gzipped");');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for gzip-only files when client does not accept gzip', async () => {
|
||||||
|
app.use(staticCache(testDir, { skipGzipScan: false }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/only-gzipped.js')
|
||||||
|
.set('Accept-Encoding', 'identity');
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle cache headers correctly for gzipped content', async () => {
|
||||||
|
app.use(staticCache(testDir, { skipGzipScan: false }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/test.js')
|
||||||
|
.set('Accept-Encoding', 'gzip')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['content-encoding']).toBe('gzip');
|
||||||
|
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
|
||||||
|
expect(response.headers['content-type']).toMatch(/javascript/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve original MIME types for gzipped files', async () => {
|
||||||
|
app.use(staticCache(testDir, { skipGzipScan: false }));
|
||||||
|
|
||||||
|
const jsResponse = await request(app)
|
||||||
|
.get('/test.js')
|
||||||
|
.set('Accept-Encoding', 'gzip')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const cssResponse = await request(app)
|
||||||
|
.get('/test.css')
|
||||||
|
.set('Accept-Encoding', 'gzip')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(jsResponse.headers['content-type']).toMatch(/javascript/);
|
||||||
|
expect(cssResponse.headers['content-type']).toMatch(/css/);
|
||||||
|
expect(jsResponse.headers['content-encoding']).toBe('gzip');
|
||||||
|
expect(cssResponse.headers['content-encoding']).toBe('gzip');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('skipGzipScan option comparison', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use express.static (no gzip) when skipGzipScan is true', async () => {
|
||||||
|
app.use(staticCache(testDir, { skipGzipScan: true }));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/test.js')
|
||||||
|
.set('Accept-Encoding', 'gzip')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// Should NOT serve gzipped version even though client accepts it
|
||||||
|
expect(response.headers['content-encoding']).toBeUndefined();
|
||||||
|
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
|
||||||
|
expect(response.text).toBe('console.log("test");');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use expressStaticGzip when skipGzipScan is false', async () => {
|
||||||
|
app.use(staticCache(testDir));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/test.js')
|
||||||
|
.set('Accept-Encoding', 'gzip')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// Should serve gzipped version when client accepts it
|
||||||
|
expect(response.headers['content-encoding']).toBe('gzip');
|
||||||
|
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
|
||||||
|
expect(response.text).toBe('console.log("test");');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('file serving', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should serve files correctly', async () => {
|
||||||
|
app.use(staticCache(testDir));
|
||||||
|
|
||||||
|
const response = await request(app).get('/test.js').expect(200);
|
||||||
|
|
||||||
|
expect(response.text).toBe('console.log("test");');
|
||||||
|
expect(response.headers['content-type']).toMatch(/javascript|text/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent files', async () => {
|
||||||
|
app.use(staticCache(testDir));
|
||||||
|
|
||||||
|
const response = await request(app).get('/nonexistent.js');
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should serve HTML files', async () => {
|
||||||
|
app.use(staticCache(testDir));
|
||||||
|
|
||||||
|
const response = await request(app).get('/index.html').expect(200);
|
||||||
|
|
||||||
|
expect(response.text).toBe('<html><body>Test</body></html>');
|
||||||
|
expect(response.headers['content-type']).toMatch(/html/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle webmanifest files', async () => {
|
||||||
|
// Create a webmanifest file
|
||||||
|
const webmanifestFile = path.join(testDir, 'site.webmanifest');
|
||||||
|
fs.writeFileSync(webmanifestFile, '{"name": "test app"}');
|
||||||
|
|
||||||
|
app.use(staticCache(testDir));
|
||||||
|
|
||||||
|
const response = await request(app).get('/site.webmanifest').expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate');
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
fs.unlinkSync(webmanifestFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle files in subdirectories', async () => {
|
||||||
|
const subDir = path.join(testDir, 'subdir');
|
||||||
|
fs.mkdirSync(subDir, { recursive: true });
|
||||||
|
const subFile = path.join(subDir, 'nested.js');
|
||||||
|
fs.writeFileSync(subFile, 'console.log("nested");');
|
||||||
|
|
||||||
|
app.use(staticCache(testDir));
|
||||||
|
|
||||||
|
const response = await request(app).get('/subdir/nested.js').expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
|
||||||
|
expect(response.text).toBe('console.log("nested");');
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
fs.rmSync(subDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const express = require('express');
|
||||||
const expressStaticGzip = require('express-static-gzip');
|
const expressStaticGzip = require('express-static-gzip');
|
||||||
|
|
||||||
const oneDayInSeconds = 24 * 60 * 60;
|
const oneDayInSeconds = 24 * 60 * 60;
|
||||||
@@ -7,44 +8,55 @@ const sMaxAge = process.env.STATIC_CACHE_S_MAX_AGE || oneDayInSeconds;
|
|||||||
const maxAge = process.env.STATIC_CACHE_MAX_AGE || oneDayInSeconds * 2;
|
const maxAge = process.env.STATIC_CACHE_MAX_AGE || oneDayInSeconds * 2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an Express static middleware with gzip compression and configurable caching
|
* Creates an Express static middleware with optional gzip compression and configurable caching
|
||||||
*
|
*
|
||||||
* @param {string} staticPath - The file system path to serve static files from
|
* @param {string} staticPath - The file system path to serve static files from
|
||||||
* @param {Object} [options={}] - Configuration options
|
* @param {Object} [options={}] - Configuration options
|
||||||
* @param {boolean} [options.noCache=false] - If true, disables caching entirely for all files
|
* @param {boolean} [options.noCache=false] - If true, disables caching entirely for all files
|
||||||
* @returns {ReturnType<expressStaticGzip>} Express middleware function for serving static files
|
* @param {boolean} [options.skipGzipScan=false] - If true, skips expressStaticGzip middleware
|
||||||
|
* @returns {ReturnType<expressStaticGzip>|ReturnType<express.static>} Express middleware function for serving static files
|
||||||
*/
|
*/
|
||||||
function staticCache(staticPath, options = {}) {
|
function staticCache(staticPath, options = {}) {
|
||||||
const { noCache = false } = options;
|
const { noCache = false, skipGzipScan = false } = options;
|
||||||
return expressStaticGzip(staticPath, {
|
|
||||||
enableBrotli: false,
|
|
||||||
orderPreference: ['gz'],
|
|
||||||
setHeaders: (res, filePath) => {
|
|
||||||
if (process.env.NODE_ENV?.toLowerCase() !== 'production') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (noCache) {
|
|
||||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (filePath.includes('/dist/images/')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fileName = path.basename(filePath);
|
|
||||||
|
|
||||||
if (
|
const setHeaders = (res, filePath) => {
|
||||||
fileName === 'index.html' ||
|
if (process.env.NODE_ENV?.toLowerCase() !== 'production') {
|
||||||
fileName.endsWith('.webmanifest') ||
|
return;
|
||||||
fileName === 'manifest.json' ||
|
}
|
||||||
fileName === 'sw.js'
|
if (noCache) {
|
||||||
) {
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
return;
|
||||||
} else {
|
}
|
||||||
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=${sMaxAge}`);
|
if (filePath && filePath.includes('/dist/images/')) {
|
||||||
}
|
return;
|
||||||
},
|
}
|
||||||
index: false,
|
const fileName = filePath ? path.basename(filePath) : '';
|
||||||
});
|
|
||||||
|
if (
|
||||||
|
fileName === 'index.html' ||
|
||||||
|
fileName.endsWith('.webmanifest') ||
|
||||||
|
fileName === 'manifest.json' ||
|
||||||
|
fileName === 'sw.js'
|
||||||
|
) {
|
||||||
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||||
|
} else {
|
||||||
|
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=${sMaxAge}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (skipGzipScan) {
|
||||||
|
return express.static(staticPath, {
|
||||||
|
setHeaders,
|
||||||
|
index: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return expressStaticGzip(staticPath, {
|
||||||
|
enableBrotli: false,
|
||||||
|
orderPreference: ['gz'],
|
||||||
|
setHeaders,
|
||||||
|
index: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = staticCache;
|
module.exports = staticCache;
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ process.env.JWT_SECRET = 'test';
|
|||||||
process.env.JWT_REFRESH_SECRET = 'test';
|
process.env.JWT_REFRESH_SECRET = 'test';
|
||||||
process.env.CREDS_KEY = 'test';
|
process.env.CREDS_KEY = 'test';
|
||||||
process.env.CREDS_IV = 'test';
|
process.env.CREDS_IV = 'test';
|
||||||
|
process.env.OPENAI_API_KEY = 'test';
|
||||||
|
|||||||
@@ -1074,7 +1074,7 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @exports JsonSchemaType
|
* @exports JsonSchemaType
|
||||||
* @typedef {import('librechat-data-provider').JsonSchemaType} JsonSchemaType
|
* @typedef {import('@librechat/api').JsonSchemaType} JsonSchemaType
|
||||||
* @memberof typedefs
|
* @memberof typedefs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -223,9 +223,17 @@ const xAIModels = {
|
|||||||
'grok-3-fast': 131072,
|
'grok-3-fast': 131072,
|
||||||
'grok-3-mini': 131072,
|
'grok-3-mini': 131072,
|
||||||
'grok-3-mini-fast': 131072,
|
'grok-3-mini-fast': 131072,
|
||||||
|
'grok-4': 256000, // 256K context
|
||||||
};
|
};
|
||||||
|
|
||||||
const aggregateModels = { ...openAIModels, ...googleModels, ...bedrockModels, ...xAIModels };
|
const aggregateModels = {
|
||||||
|
...openAIModels,
|
||||||
|
...googleModels,
|
||||||
|
...bedrockModels,
|
||||||
|
...xAIModels,
|
||||||
|
// misc.
|
||||||
|
kimi: 131000,
|
||||||
|
};
|
||||||
|
|
||||||
const maxTokensMap = {
|
const maxTokensMap = {
|
||||||
[EModelEndpoint.azureOpenAI]: openAIModels,
|
[EModelEndpoint.azureOpenAI]: openAIModels,
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ describe('matchModelName', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return the closest matching key for gpt-4-1106 partial matches', () => {
|
it('should return the closest matching key for gpt-4-1106 partial matches', () => {
|
||||||
expect(matchModelName('something/gpt-4-1106')).toBe('gpt-4-1106');
|
expect(matchModelName('gpt-4-1106/something')).toBe('gpt-4-1106');
|
||||||
expect(matchModelName('gpt-4-1106-preview')).toBe('gpt-4-1106');
|
expect(matchModelName('gpt-4-1106-preview')).toBe('gpt-4-1106');
|
||||||
expect(matchModelName('gpt-4-1106-vision-preview')).toBe('gpt-4-1106');
|
expect(matchModelName('gpt-4-1106-vision-preview')).toBe('gpt-4-1106');
|
||||||
});
|
});
|
||||||
@@ -589,6 +589,10 @@ describe('Grok Model Tests - Tokens', () => {
|
|||||||
expect(getModelMaxTokens('grok-3-mini-fast')).toBe(131072);
|
expect(getModelMaxTokens('grok-3-mini-fast')).toBe(131072);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should return correct tokens for Grok 4 model', () => {
|
||||||
|
expect(getModelMaxTokens('grok-4-0709')).toBe(256000);
|
||||||
|
});
|
||||||
|
|
||||||
test('should handle partial matches for Grok models with prefixes', () => {
|
test('should handle partial matches for Grok models with prefixes', () => {
|
||||||
// Vision models should match before general models
|
// Vision models should match before general models
|
||||||
expect(getModelMaxTokens('xai/grok-2-vision-1212')).toBe(32768);
|
expect(getModelMaxTokens('xai/grok-2-vision-1212')).toBe(32768);
|
||||||
@@ -606,6 +610,8 @@ describe('Grok Model Tests - Tokens', () => {
|
|||||||
expect(getModelMaxTokens('xai/grok-3-fast')).toBe(131072);
|
expect(getModelMaxTokens('xai/grok-3-fast')).toBe(131072);
|
||||||
expect(getModelMaxTokens('xai/grok-3-mini')).toBe(131072);
|
expect(getModelMaxTokens('xai/grok-3-mini')).toBe(131072);
|
||||||
expect(getModelMaxTokens('xai/grok-3-mini-fast')).toBe(131072);
|
expect(getModelMaxTokens('xai/grok-3-mini-fast')).toBe(131072);
|
||||||
|
// Grok 4 model
|
||||||
|
expect(getModelMaxTokens('xai/grok-4-0709')).toBe(256000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -627,6 +633,8 @@ describe('Grok Model Tests - Tokens', () => {
|
|||||||
expect(matchModelName('grok-3-fast')).toBe('grok-3-fast');
|
expect(matchModelName('grok-3-fast')).toBe('grok-3-fast');
|
||||||
expect(matchModelName('grok-3-mini')).toBe('grok-3-mini');
|
expect(matchModelName('grok-3-mini')).toBe('grok-3-mini');
|
||||||
expect(matchModelName('grok-3-mini-fast')).toBe('grok-3-mini-fast');
|
expect(matchModelName('grok-3-mini-fast')).toBe('grok-3-mini-fast');
|
||||||
|
// Grok 4 model
|
||||||
|
expect(matchModelName('grok-4-0709')).toBe('grok-4');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should match Grok model variations with prefixes', () => {
|
test('should match Grok model variations with prefixes', () => {
|
||||||
@@ -646,6 +654,8 @@ describe('Grok Model Tests - Tokens', () => {
|
|||||||
expect(matchModelName('xai/grok-3-fast')).toBe('grok-3-fast');
|
expect(matchModelName('xai/grok-3-fast')).toBe('grok-3-fast');
|
||||||
expect(matchModelName('xai/grok-3-mini')).toBe('grok-3-mini');
|
expect(matchModelName('xai/grok-3-mini')).toBe('grok-3-mini');
|
||||||
expect(matchModelName('xai/grok-3-mini-fast')).toBe('grok-3-mini-fast');
|
expect(matchModelName('xai/grok-3-mini-fast')).toBe('grok-3-mini-fast');
|
||||||
|
// Grok 4 model
|
||||||
|
expect(matchModelName('xai/grok-4-0709')).toBe('grok-4');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -704,3 +714,45 @@ describe('Claude Model Tests', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Kimi Model Tests', () => {
|
||||||
|
describe('getModelMaxTokens', () => {
|
||||||
|
test('should return correct tokens for Kimi models', () => {
|
||||||
|
expect(getModelMaxTokens('kimi')).toBe(131000);
|
||||||
|
expect(getModelMaxTokens('kimi-k2')).toBe(131000);
|
||||||
|
expect(getModelMaxTokens('kimi-vl')).toBe(131000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return correct tokens for Kimi models with provider prefix', () => {
|
||||||
|
expect(getModelMaxTokens('moonshotai/kimi-k2')).toBe(131000);
|
||||||
|
expect(getModelMaxTokens('moonshotai/kimi')).toBe(131000);
|
||||||
|
expect(getModelMaxTokens('moonshotai/kimi-vl')).toBe(131000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle partial matches for Kimi models', () => {
|
||||||
|
expect(getModelMaxTokens('kimi-k2-latest')).toBe(131000);
|
||||||
|
expect(getModelMaxTokens('kimi-vl-preview')).toBe(131000);
|
||||||
|
expect(getModelMaxTokens('kimi-2024')).toBe(131000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('matchModelName', () => {
|
||||||
|
test('should match exact Kimi model names', () => {
|
||||||
|
expect(matchModelName('kimi')).toBe('kimi');
|
||||||
|
expect(matchModelName('kimi-k2')).toBe('kimi');
|
||||||
|
expect(matchModelName('kimi-vl')).toBe('kimi');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should match Kimi model variations with provider prefix', () => {
|
||||||
|
expect(matchModelName('moonshotai/kimi')).toBe('kimi');
|
||||||
|
expect(matchModelName('moonshotai/kimi-k2')).toBe('kimi');
|
||||||
|
expect(matchModelName('moonshotai/kimi-vl')).toBe('kimi');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should match Kimi model variations with suffixes', () => {
|
||||||
|
expect(matchModelName('kimi-k2-latest')).toBe('kimi');
|
||||||
|
expect(matchModelName('kimi-vl-preview')).toBe('kimi');
|
||||||
|
expect(matchModelName('kimi-2024')).toBe('kimi');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@librechat/frontend",
|
"name": "@librechat/frontend",
|
||||||
"version": "v0.7.9-rc1",
|
"version": "v0.7.9",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"@dicebear/collection": "^9.2.2",
|
"@dicebear/collection": "^9.2.2",
|
||||||
"@dicebear/core": "^9.2.2",
|
"@dicebear/core": "^9.2.2",
|
||||||
"@headlessui/react": "^2.1.2",
|
"@headlessui/react": "^2.1.2",
|
||||||
|
"@librechat/client": "*",
|
||||||
"@marsidev/react-turnstile": "^1.1.0",
|
"@marsidev/react-turnstile": "^1.1.0",
|
||||||
"@radix-ui/react-accordion": "^1.1.2",
|
"@radix-ui/react-accordion": "^1.1.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.0.2",
|
"@radix-ui/react-alert-dialog": "^1.0.2",
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
"i18next": "^24.2.2",
|
"i18next": "^24.2.2",
|
||||||
"i18next-browser-languagedetector": "^8.0.3",
|
"i18next-browser-languagedetector": "^8.0.3",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
|
"jotai": "^2.12.5",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"librechat-data-provider": "*",
|
"librechat-data-provider": "*",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import { RouterProvider } from 'react-router-dom';
|
|||||||
import * as RadixToast from '@radix-ui/react-toast';
|
import * as RadixToast from '@radix-ui/react-toast';
|
||||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
|
import { Toast, ThemeProvider, ToastProvider } from '@librechat/client';
|
||||||
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
|
||||||
import { ScreenshotProvider, ThemeProvider, useApiErrorBoundary } from './hooks';
|
import { ScreenshotProvider, useApiErrorBoundary } from './hooks';
|
||||||
import { ToastProvider } from './Providers';
|
|
||||||
import Toast from './components/ui/Toast';
|
|
||||||
import { LiveAnnouncer } from '~/a11y';
|
import { LiveAnnouncer } from '~/a11y';
|
||||||
import { router } from './routes';
|
import { router } from './routes';
|
||||||
|
|
||||||
|
|||||||
31
client/src/Providers/SidePanelContext.tsx
Normal file
31
client/src/Providers/SidePanelContext.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import React, { createContext, useContext, useMemo } from 'react';
|
||||||
|
import type { EModelEndpoint } from 'librechat-data-provider';
|
||||||
|
import { useChatContext } from './ChatContext';
|
||||||
|
|
||||||
|
interface SidePanelContextValue {
|
||||||
|
endpoint?: EModelEndpoint | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidePanelContext = createContext<SidePanelContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
export function SidePanelProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { conversation } = useChatContext();
|
||||||
|
|
||||||
|
/** Context value only created when endpoint changes */
|
||||||
|
const contextValue = useMemo<SidePanelContextValue>(
|
||||||
|
() => ({
|
||||||
|
endpoint: conversation?.endpoint,
|
||||||
|
}),
|
||||||
|
[conversation?.endpoint],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SidePanelContext.Provider value={contextValue}>{children}</SidePanelContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidePanelContext() {
|
||||||
|
const context = useContext(SidePanelContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSidePanelContext must be used within SidePanelProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user