Compare commits
114 Commits
a11y/focus
...
refactor/A
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
924276d4b9 | ||
|
|
b64265e5bf | ||
|
|
28b76ce339 | ||
|
|
eb1668ff22 | ||
|
|
e86842fd19 | ||
|
|
af96666ff4 | ||
|
|
59109cd2dd | ||
|
|
c8f5f5131e | ||
|
|
8c0be0e2f0 | ||
|
|
f8cb0cdcda | ||
|
|
55d52d07f2 | ||
|
|
7ce782fec6 | ||
|
|
c79ee32006 | ||
|
|
739b0d3012 | ||
|
|
9c9fe4e03a | ||
|
|
844bbbb162 | ||
|
|
26780bddf0 | ||
|
|
353adceb0c | ||
|
|
a92ac23c44 | ||
|
|
2a3bf259aa | ||
|
|
3152a1e536 | ||
|
|
2f4a03b581 | ||
|
|
7a91f6ca62 | ||
|
|
fe311df969 | ||
|
|
535e7798b3 | ||
|
|
621fa6e1aa | ||
|
|
f6cc394eab | ||
|
|
438392f705 | ||
|
|
c925f9f39c | ||
|
|
71effb1a66 | ||
|
|
e3acd18c07 | ||
|
|
5b402a755e | ||
|
|
b0405be9ea | ||
|
|
3f4dd08589 | ||
|
|
d14a063302 | ||
|
|
d5b399550e | ||
|
|
a5ff8253a4 | ||
|
|
0b44142383 | ||
|
|
502617db24 | ||
|
|
f2f285ca1e | ||
|
|
6dd1b39886 | ||
|
|
5a43f87584 | ||
|
|
4af72aac9b | ||
|
|
f7777a2723 | ||
|
|
e5b234bc72 | ||
|
|
4f2ed46450 | ||
|
|
66093b1eb3 | ||
|
|
d7390d24ec | ||
|
|
71105cd49c | ||
|
|
3606349a0f | ||
|
|
e3e796293c | ||
|
|
7c4c3a8796 | ||
|
|
20c9f1a783 | ||
|
|
8e1012c5aa | ||
|
|
7c92cef2b7 | ||
|
|
4fbb81c774 | ||
|
|
fc6e14efe2 | ||
|
|
6e663b2480 | ||
|
|
ddb2141eac | ||
|
|
37b50736bc | ||
|
|
5d6d13efe8 | ||
|
|
5efad8f646 | ||
|
|
9a7f763714 | ||
|
|
e6e7935fd8 | ||
|
|
18dc3f8686 | ||
|
|
fe512005fc | ||
|
|
da131b6c59 | ||
|
|
dd23559d1f | ||
|
|
a6f0a8244f | ||
|
|
f04f8f53be | ||
|
|
a89a3f4146 | ||
|
|
55f5f2d11a | ||
|
|
0e8041bcac | ||
|
|
fc30482f65 | ||
|
|
6826c0ed43 | ||
|
|
550c7cc68a | ||
|
|
c0ebb434a6 | ||
|
|
0ee1dcc479 | ||
|
|
e467fbebfa | ||
|
|
7f1d01c35a | ||
|
|
150116eefe | ||
|
|
52f146dd97 | ||
|
|
88f4ad7c47 | ||
|
|
851938e7a6 | ||
|
|
6edd93f99e | ||
|
|
16aa5ed466 | ||
|
|
000f3a3733 | ||
|
|
d32f34e5d7 | ||
|
|
650e9b4f6c | ||
|
|
77a21719fd | ||
|
|
d0332c6e07 | ||
|
|
5d56f48879 | ||
|
|
c49f883e1a | ||
|
|
52b3ed54ca | ||
|
|
64bd373bc8 | ||
|
|
339882eea4 | ||
|
|
37964975c1 | ||
|
|
1e6b1b9554 | ||
|
|
12f4dbb8c5 | ||
|
|
e16a6190a5 | ||
|
|
24c0433dcf | ||
|
|
5d668748f9 | ||
|
|
910c73359b | ||
|
|
018143b5cc | ||
|
|
4afab52fc5 | ||
|
|
175cfe8ffb | ||
|
|
9b0678da16 | ||
|
|
ac35b8490c | ||
|
|
0551a562d8 | ||
|
|
710fde6a6f | ||
|
|
93e679e173 | ||
|
|
cff392e578 | ||
|
|
a36426ef54 | ||
|
|
edcac7669b |
20
.env.example
20
.env.example
@@ -142,12 +142,12 @@ GOOGLE_KEY=user_provided
|
|||||||
# GOOGLE_AUTH_HEADER=true
|
# GOOGLE_AUTH_HEADER=true
|
||||||
|
|
||||||
# Gemini API (AI Studio)
|
# Gemini API (AI Studio)
|
||||||
# GOOGLE_MODELS=gemini-2.5-pro-exp-03-25,gemini-2.0-flash-exp,gemini-2.0-flash-thinking-exp-1219,gemini-exp-1121,gemini-exp-1114,gemini-1.5-flash-latest,gemini-1.0-pro,gemini-1.0-pro-001,gemini-1.0-pro-latest,gemini-1.0-pro-vision-latest,gemini-1.5-pro-latest,gemini-pro,gemini-pro-vision
|
# GOOGLE_MODELS=gemini-2.5-pro-preview-05-06,gemini-2.5-flash-preview-04-17,gemini-2.0-flash-001,gemini-2.0-flash-exp,gemini-2.0-flash-lite-001,gemini-1.5-pro-002,gemini-1.5-flash-002
|
||||||
|
|
||||||
# Vertex AI
|
# Vertex AI
|
||||||
# GOOGLE_MODELS=gemini-1.5-flash-preview-0514,gemini-1.5-pro-preview-0514,gemini-1.0-pro-vision-001,gemini-1.0-pro-002,gemini-1.0-pro-001,gemini-pro-vision,gemini-1.0-pro
|
# GOOGLE_MODELS=gemini-2.5-pro-preview-05-06,gemini-2.5-flash-preview-04-17,gemini-2.0-flash-001,gemini-2.0-flash-exp,gemini-2.0-flash-lite-001,gemini-1.5-pro-002,gemini-1.5-flash-002
|
||||||
|
|
||||||
# GOOGLE_TITLE_MODEL=gemini-pro
|
# GOOGLE_TITLE_MODEL=gemini-2.0-flash-lite-001
|
||||||
|
|
||||||
# GOOGLE_LOC=us-central1
|
# GOOGLE_LOC=us-central1
|
||||||
|
|
||||||
@@ -231,6 +231,14 @@ AZURE_AI_SEARCH_SEARCH_OPTION_QUERY_TYPE=
|
|||||||
AZURE_AI_SEARCH_SEARCH_OPTION_TOP=
|
AZURE_AI_SEARCH_SEARCH_OPTION_TOP=
|
||||||
AZURE_AI_SEARCH_SEARCH_OPTION_SELECT=
|
AZURE_AI_SEARCH_SEARCH_OPTION_SELECT=
|
||||||
|
|
||||||
|
# OpenAI Image Tools Customization
|
||||||
|
#----------------
|
||||||
|
# IMAGE_GEN_OAI_DESCRIPTION_WITH_FILES=Custom description for image generation tool when files are present
|
||||||
|
# IMAGE_GEN_OAI_DESCRIPTION_NO_FILES=Custom description for image generation tool when no files are present
|
||||||
|
# IMAGE_EDIT_OAI_DESCRIPTION=Custom description for image editing tool
|
||||||
|
# IMAGE_GEN_OAI_PROMPT_DESCRIPTION=Custom prompt description for image generation tool
|
||||||
|
# IMAGE_EDIT_OAI_PROMPT_DESCRIPTION=Custom prompt description for image editing tool
|
||||||
|
|
||||||
# DALL·E
|
# DALL·E
|
||||||
#----------------
|
#----------------
|
||||||
# DALLE_API_KEY=
|
# DALLE_API_KEY=
|
||||||
@@ -555,9 +563,9 @@ HELP_AND_FAQ_URL=https://librechat.ai
|
|||||||
# users always get the latest version. Customize #
|
# users always get the latest version. Customize #
|
||||||
# only if you understand caching implications. #
|
# only if you understand caching implications. #
|
||||||
|
|
||||||
# INDEX_HTML_CACHE_CONTROL=no-cache, no-store, must-revalidate
|
# INDEX_CACHE_CONTROL=no-cache, no-store, must-revalidate
|
||||||
# INDEX_HTML_PRAGMA=no-cache
|
# INDEX_PRAGMA=no-cache
|
||||||
# INDEX_HTML_EXPIRES=0
|
# INDEX_EXPIRES=0
|
||||||
|
|
||||||
# no-cache: Forces validation with server before using cached version
|
# no-cache: Forces validation with server before using cached version
|
||||||
# no-store: Prevents storing the response entirely
|
# no-store: Prevents storing the response entirely
|
||||||
|
|||||||
50
.github/CONTRIBUTING.md
vendored
50
.github/CONTRIBUTING.md
vendored
@@ -24,22 +24,40 @@ Project maintainers have the right and responsibility to remove, edit, or reject
|
|||||||
|
|
||||||
## To contribute to this project, please adhere to the following guidelines:
|
## To contribute to this project, please adhere to the following guidelines:
|
||||||
|
|
||||||
## 1. Development notes
|
## 1. Development Setup
|
||||||
|
|
||||||
1. Before starting work, make sure your main branch has the latest commits with `npm run update`
|
1. Use Node.JS 20.x.
|
||||||
2. Run linting command to find errors: `npm run lint`. Alternatively, ensure husky pre-commit checks are functioning.
|
2. Install typescript globally: `npm i -g typescript`.
|
||||||
|
3. Run `npm ci` to install dependencies.
|
||||||
|
4. Build the data provider: `npm run build:data-provider`.
|
||||||
|
5. Build MCP: `npm run build:mcp`.
|
||||||
|
6. Build data schemas: `npm run build:data-schemas`.
|
||||||
|
7. Setup and run unit tests:
|
||||||
|
- Copy `.env.test`: `cp api/test/.env.test.example api/test/.env.test`.
|
||||||
|
- Run backend unit tests: `npm run test:api`.
|
||||||
|
- Run frontend unit tests: `npm run test:client`.
|
||||||
|
8. Setup and run integration tests:
|
||||||
|
- Build client: `cd client && npm run build`.
|
||||||
|
- Create `.env`: `cp .env.example .env`.
|
||||||
|
- Install [MongoDB Community Edition](https://www.mongodb.com/docs/manual/administration/install-community/), ensure that `mongosh` connects to your local instance.
|
||||||
|
- Run: `npx install playwright`, then `npx playwright install`.
|
||||||
|
- Copy `config.local`: `cp e2e/config.local.example.ts e2e/config.local.ts`.
|
||||||
|
- Copy `librechat.yaml`: `cp librechat.example.yaml librechat.yaml`.
|
||||||
|
- Run: `npm run e2e`.
|
||||||
|
|
||||||
|
## 2. Development Notes
|
||||||
|
|
||||||
|
1. Before starting work, make sure your main branch has the latest commits with `npm run update`.
|
||||||
|
3. Run linting command to find errors: `npm run lint`. Alternatively, ensure husky pre-commit checks are functioning.
|
||||||
3. After your changes, reinstall packages in your current branch using `npm run reinstall` and ensure everything still works.
|
3. After your changes, reinstall packages in your current branch using `npm run reinstall` and ensure everything still works.
|
||||||
- Restart the ESLint server ("ESLint: Restart ESLint Server" in VS Code command bar) and your IDE after reinstalling or updating.
|
- Restart the ESLint server ("ESLint: Restart ESLint Server" in VS Code command bar) and your IDE after reinstalling or updating.
|
||||||
4. Clear web app localStorage and cookies before and after changes.
|
4. Clear web app localStorage and cookies before and after changes.
|
||||||
5. For frontend changes:
|
5. For frontend changes, compile typescript before and after changes to check for introduced errors: `cd client && npm run build`.
|
||||||
- Install typescript globally: `npm i -g typescript`.
|
6. Run backend unit tests: `npm run test:api`.
|
||||||
- Compile typescript before and after changes to check for introduced errors: `cd client && tsc --noEmit`.
|
7. Run frontend unit tests: `npm run test:client`.
|
||||||
6. Run tests locally:
|
8. Run integration tests: `npm run e2e`.
|
||||||
- Backend unit tests: `npm run test:api`
|
|
||||||
- Frontend unit tests: `npm run test:client`
|
|
||||||
- Integration tests: `npm run e2e` (requires playwright installed, `npx install playwright`)
|
|
||||||
|
|
||||||
## 2. Git Workflow
|
## 3. Git Workflow
|
||||||
|
|
||||||
We utilize a GitFlow workflow to manage changes to this project's codebase. Follow these general steps when contributing code:
|
We utilize a GitFlow workflow to manage changes to this project's codebase. Follow these general steps when contributing code:
|
||||||
|
|
||||||
@@ -49,7 +67,7 @@ We utilize a GitFlow workflow to manage changes to this project's codebase. Foll
|
|||||||
4. Submit a pull request with a clear and concise description of your changes and the reasons behind them.
|
4. Submit a pull request with a clear and concise description of your changes and the reasons behind them.
|
||||||
5. We will review your pull request, provide feedback as needed, and eventually merge the approved changes into the main branch.
|
5. We will review your pull request, provide feedback as needed, and eventually merge the approved changes into the main branch.
|
||||||
|
|
||||||
## 3. Commit Message Format
|
## 4. Commit Message Format
|
||||||
|
|
||||||
We follow the [semantic format](https://gist.github.com/joshbuchea/6f47e86d2510bce28f8e7f42ae84c716) for commit messages.
|
We follow the [semantic format](https://gist.github.com/joshbuchea/6f47e86d2510bce28f8e7f42ae84c716) for commit messages.
|
||||||
|
|
||||||
@@ -76,7 +94,7 @@ feat: add hat wobble
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## 4. Pull Request Process
|
## 5. Pull Request Process
|
||||||
|
|
||||||
When submitting a pull request, please follow these guidelines:
|
When submitting a pull request, please follow these guidelines:
|
||||||
|
|
||||||
@@ -91,7 +109,7 @@ Ensure that your changes meet the following criteria:
|
|||||||
- The commit history is clean and easy to follow. You can use `git rebase` or `git merge --squash` to clean your commit history before submitting the pull request.
|
- The commit history is clean and easy to follow. You can use `git rebase` or `git merge --squash` to clean your commit history before submitting the pull request.
|
||||||
- The pull request description clearly outlines the changes and the reasons behind them. Be sure to include the steps to test the pull request.
|
- The pull request description clearly outlines the changes and the reasons behind them. Be sure to include the steps to test the pull request.
|
||||||
|
|
||||||
## 5. Naming Conventions
|
## 6. Naming Conventions
|
||||||
|
|
||||||
Apply the following naming conventions to branches, labels, and other Git-related entities:
|
Apply the following naming conventions to branches, labels, and other Git-related entities:
|
||||||
|
|
||||||
@@ -100,7 +118,7 @@ Apply the following naming conventions to branches, labels, and other Git-relate
|
|||||||
- **JS/TS:** Directories and file names: Descriptive and camelCase. First letter uppercased for React files (e.g., `helperFunction.ts, ReactComponent.tsx`).
|
- **JS/TS:** Directories and file names: Descriptive and camelCase. First letter uppercased for React files (e.g., `helperFunction.ts, ReactComponent.tsx`).
|
||||||
- **Docs:** Directories and file names: Descriptive and snake_case (e.g., `config_files.md`).
|
- **Docs:** Directories and file names: Descriptive and snake_case (e.g., `config_files.md`).
|
||||||
|
|
||||||
## 6. TypeScript Conversion
|
## 7. TypeScript Conversion
|
||||||
|
|
||||||
1. **Original State**: The project was initially developed entirely in JavaScript (JS).
|
1. **Original State**: The project was initially developed entirely in JavaScript (JS).
|
||||||
|
|
||||||
@@ -126,7 +144,7 @@ Apply the following naming conventions to branches, labels, and other Git-relate
|
|||||||
|
|
||||||
- **Current Stance**: At present, this backend transition is of lower priority and might not be pursued.
|
- **Current Stance**: At present, this backend transition is of lower priority and might not be pursued.
|
||||||
|
|
||||||
## 7. Module Import Conventions
|
## 8. Module Import Conventions
|
||||||
|
|
||||||
- `npm` packages first,
|
- `npm` packages first,
|
||||||
- from shortest line (top) to longest (bottom)
|
- from shortest line (top) to longest (bottom)
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/BUG-REPORT.yml
vendored
2
.github/ISSUE_TEMPLATE/BUG-REPORT.yml
vendored
@@ -79,6 +79,8 @@ body:
|
|||||||
|
|
||||||
For UI-related issues, browser console logs can be very helpful. You can provide these as screenshots or paste the text here.
|
For UI-related issues, browser console logs can be very helpful. You can provide these as screenshots or paste the text here.
|
||||||
render: shell
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: screenshots
|
id: screenshots
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*'
|
- 'v*.*.*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
generate-release-changelog-pr:
|
generate-release-changelog-pr:
|
||||||
@@ -88,7 +89,7 @@ jobs:
|
|||||||
base: main
|
base: main
|
||||||
branch: "changelog/${{ github.ref_name }}"
|
branch: "changelog/${{ github.ref_name }}"
|
||||||
reviewers: danny-avila
|
reviewers: danny-avila
|
||||||
title: "chore: update CHANGELOG for release ${{ github.ref_name }}"
|
title: "📜 docs: Changelog for release ${{ github.ref_name }}"
|
||||||
body: |
|
body: |
|
||||||
**Description**:
|
**Description**:
|
||||||
- This PR updates the CHANGELOG.md by removing the "Unreleased" section and adding new release notes for release ${{ github.ref_name }} above previous releases.
|
- This PR updates the CHANGELOG.md by removing the "Unreleased" section and adding new release notes for release ${{ github.ref_name }} above previous releases.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ name: Generate Unreleased Changelog PR
|
|||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 0 * * 1" # Runs every Monday at 00:00 UTC
|
- cron: "0 0 * * 1" # Runs every Monday at 00:00 UTC
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
generate-unreleased-changelog-pr:
|
generate-unreleased-changelog-pr:
|
||||||
@@ -98,9 +99,9 @@ jobs:
|
|||||||
branch: "changelog/unreleased-update"
|
branch: "changelog/unreleased-update"
|
||||||
sign-commits: true
|
sign-commits: true
|
||||||
commit-message: "action: update Unreleased changelog"
|
commit-message: "action: update Unreleased changelog"
|
||||||
title: "action: update Unreleased changelog"
|
title: "📜 docs: Unreleased Changelog"
|
||||||
body: |
|
body: |
|
||||||
**Description**:
|
**Description**:
|
||||||
- This PR updates the Unreleased section in CHANGELOG.md.
|
- 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 }}),
|
- It compares the current main branch with the latest version tag (determined as ${{ steps.get_latest_tag.outputs.tag }}),
|
||||||
regenerates the Unreleased changelog, removes any old Unreleased block, and inserts the new content.
|
regenerates the Unreleased changelog, removes any old Unreleased block, and inserts the new content.
|
||||||
|
|||||||
3
.github/workflows/helmcharts.yml
vendored
3
.github/workflows/helmcharts.yml
vendored
@@ -29,5 +29,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Run chart-releaser
|
- name: Run chart-releaser
|
||||||
uses: helm/chart-releaser-action@v1.6.0
|
uses: helm/chart-releaser-action@v1.6.0
|
||||||
|
with:
|
||||||
|
charts_dir: helm
|
||||||
|
skip_existing: true
|
||||||
env:
|
env:
|
||||||
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
|||||||
37
.github/workflows/i18n-unused-keys.yml
vendored
37
.github/workflows/i18n-unused-keys.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
|||||||
|
|
||||||
# Define paths
|
# Define paths
|
||||||
I18N_FILE="client/src/locales/en/translation.json"
|
I18N_FILE="client/src/locales/en/translation.json"
|
||||||
SOURCE_DIRS=("client/src" "api")
|
SOURCE_DIRS=("client/src" "api" "packages/data-provider/src")
|
||||||
|
|
||||||
# Check if translation file exists
|
# Check if translation file exists
|
||||||
if [[ ! -f "$I18N_FILE" ]]; then
|
if [[ ! -f "$I18N_FILE" ]]; then
|
||||||
@@ -39,12 +39,35 @@ jobs:
|
|||||||
# Check if each key is used in the source code
|
# Check if each key is used in the source code
|
||||||
for KEY in $KEYS; do
|
for KEY in $KEYS; do
|
||||||
FOUND=false
|
FOUND=false
|
||||||
for DIR in "${SOURCE_DIRS[@]}"; do
|
|
||||||
if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
|
# Special case for dynamically constructed special variable keys
|
||||||
FOUND=true
|
if [[ "$KEY" == com_ui_special_var_* ]]; then
|
||||||
break
|
# Check if TSpecialVarLabel is used in the codebase
|
||||||
|
for DIR in "${SOURCE_DIRS[@]}"; do
|
||||||
|
if grep -r --include=\*.{js,jsx,ts,tsx} -q "TSpecialVarLabel" "$DIR"; then
|
||||||
|
FOUND=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Also check if the key is directly used somewhere
|
||||||
|
if [[ "$FOUND" == false ]]; then
|
||||||
|
for DIR in "${SOURCE_DIRS[@]}"; do
|
||||||
|
if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
|
||||||
|
FOUND=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
fi
|
fi
|
||||||
done
|
else
|
||||||
|
# Regular check for other keys
|
||||||
|
for DIR in "${SOURCE_DIRS[@]}"; do
|
||||||
|
if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
|
||||||
|
FOUND=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$FOUND" == false ]]; then
|
if [[ "$FOUND" == false ]]; then
|
||||||
UNUSED_KEYS+=("$KEY")
|
UNUSED_KEYS+=("$KEY")
|
||||||
@@ -90,4 +113,4 @@ jobs:
|
|||||||
|
|
||||||
- name: Fail workflow if unused keys found
|
- name: Fail workflow if unused keys found
|
||||||
if: env.unused_keys != '[]'
|
if: env.unused_keys != '[]'
|
||||||
run: exit 1
|
run: exit 1
|
||||||
|
|||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -52,6 +52,9 @@ bower_components/
|
|||||||
*.d.ts
|
*.d.ts
|
||||||
!vite-env.d.ts
|
!vite-env.d.ts
|
||||||
|
|
||||||
|
# Cline
|
||||||
|
.clineignore
|
||||||
|
|
||||||
# Floobits
|
# Floobits
|
||||||
.floo
|
.floo
|
||||||
.floobit
|
.floobit
|
||||||
@@ -110,4 +113,11 @@ uploads/
|
|||||||
|
|
||||||
# owner
|
# owner
|
||||||
release/
|
release/
|
||||||
|
|
||||||
|
# Helm
|
||||||
|
helm/librechat/Chart.lock
|
||||||
|
helm/**/charts/
|
||||||
|
helm/**/.values.yaml
|
||||||
|
|
||||||
!/client/src/@types/i18next.d.ts
|
!/client/src/@types/i18next.d.ts
|
||||||
|
|
||||||
|
|||||||
217
CHANGELOG.md
217
CHANGELOG.md
@@ -2,15 +2,226 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### ✨ New Features
|
### ✨ New Features
|
||||||
|
|
||||||
- 🪄 feat: Agent Artifacts by **@danny-avila** in [#5804](https://github.com/danny-avila/LibreChat/pull/5804)
|
- ✨ feat: implement search parameter updates by **@mawburn** in [#7151](https://github.com/danny-avila/LibreChat/pull/7151)
|
||||||
|
- 🎏 feat: Add MCP support for Streamable HTTP Transport by **@benverhees** in [#7353](https://github.com/danny-avila/LibreChat/pull/7353)
|
||||||
|
- 🔒 feat: Add Content Security Policy using Helmet middleware by **@rubentalstra** in [#7377](https://github.com/danny-avila/LibreChat/pull/7377)
|
||||||
|
- ✨ feat: Add Normalization for MCP Server Names by **@danny-avila** in [#7421](https://github.com/danny-avila/LibreChat/pull/7421)
|
||||||
|
- 📊 feat: Improve Helm Chart by **@hofq** in [#3638](https://github.com/danny-avila/LibreChat/pull/3638)
|
||||||
|
|
||||||
|
### 🌍 Internationalization
|
||||||
|
|
||||||
|
- 🌍 i18n: Add `Danish` and `Czech` and `Catalan` localization support by **@rubentalstra** in [#7373](https://github.com/danny-avila/LibreChat/pull/7373)
|
||||||
|
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7375](https://github.com/danny-avila/LibreChat/pull/7375)
|
||||||
|
|
||||||
|
### 🔧 Fixes
|
||||||
|
|
||||||
|
- 💬 fix: update aria-label for accessibility in ConvoLink component by **@berry-13** in [#7320](https://github.com/danny-avila/LibreChat/pull/7320)
|
||||||
|
- 🔑 fix: use `apiKey` instead of `openAIApiKey` in OpenAI-like Config by **@danny-avila** in [#7337](https://github.com/danny-avila/LibreChat/pull/7337)
|
||||||
|
- 🔄 fix: update navigation logic in `useFocusChatEffect` to ensure correct search parameters are used by **@mawburn** in [#7340](https://github.com/danny-avila/LibreChat/pull/7340)
|
||||||
|
- 🔄 fix: Improve MCP Connection Cleanup by **@danny-avila** in [#7400](https://github.com/danny-avila/LibreChat/pull/7400)
|
||||||
|
- 🛡️ fix: Preset and Validation Logic for URL Query Params by **@danny-avila** in [#7407](https://github.com/danny-avila/LibreChat/pull/7407)
|
||||||
|
- 🌘 fix: artifact of preview text is illegible in dark mode by **@nhtruong** in [#7405](https://github.com/danny-avila/LibreChat/pull/7405)
|
||||||
|
- 🛡️ fix: Temporarily Remove CSP until Configurable by **@danny-avila** in [#7419](https://github.com/danny-avila/LibreChat/pull/7419)
|
||||||
|
- 💽 fix: Exclude index page `/` from static cache settings by **@sbruel** in [#7382](https://github.com/danny-avila/LibreChat/pull/7382)
|
||||||
|
|
||||||
### ⚙️ Other Changes
|
### ⚙️ Other Changes
|
||||||
|
|
||||||
- 🔄 chore: Enforce 18next Language Keys by **@rubentalstra** in [#5803](https://github.com/danny-avila/LibreChat/pull/5803)
|
- 📜 docs: CHANGELOG for release v0.7.8 by **@github-actions[bot]** in [#7290](https://github.com/danny-avila/LibreChat/pull/7290)
|
||||||
- 🔃 refactor: Parent Message ID Handling on Error, Update Translations, Bump Agents by **@danny-avila** in [#5833](https://github.com/danny-avila/LibreChat/pull/5833)
|
- 📦 chore: Update API Package Dependencies by **@danny-avila** in [#7359](https://github.com/danny-avila/LibreChat/pull/7359)
|
||||||
|
- 📜 docs: Unreleased Changelog by **@github-actions[bot]** in [#7321](https://github.com/danny-avila/LibreChat/pull/7321)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
## [v0.7.8] -
|
||||||
|
|
||||||
|
Changes from v0.7.8-rc1 to v0.7.8.
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
- ✨ feat: Enhance form submission for touch screens by **@berry-13** in [#7198](https://github.com/danny-avila/LibreChat/pull/7198)
|
||||||
|
- 🔍 feat: Additional Tavily API Tool Parameters by **@glowforge-opensource** in [#7232](https://github.com/danny-avila/LibreChat/pull/7232)
|
||||||
|
- 🐋 feat: Add python to Dockerfile for increased MCP compatibility by **@technicalpickles** in [#7270](https://github.com/danny-avila/LibreChat/pull/7270)
|
||||||
|
|
||||||
|
### 🔧 Fixes
|
||||||
|
|
||||||
|
- 🔧 fix: Google Gemma Support & OpenAI Reasoning Instructions by **@danny-avila** in [#7196](https://github.com/danny-avila/LibreChat/pull/7196)
|
||||||
|
- 🛠️ fix: Conversation Navigation State by **@danny-avila** in [#7210](https://github.com/danny-avila/LibreChat/pull/7210)
|
||||||
|
- 🔄 fix: o-Series Model Regex for System Messages by **@danny-avila** in [#7245](https://github.com/danny-avila/LibreChat/pull/7245)
|
||||||
|
- 🔖 fix: Custom Headers for Initial MCP SSE Connection by **@danny-avila** in [#7246](https://github.com/danny-avila/LibreChat/pull/7246)
|
||||||
|
- 🛡️ fix: Deep Clone `MCPOptions` for User MCP Connections by **@danny-avila** in [#7247](https://github.com/danny-avila/LibreChat/pull/7247)
|
||||||
|
- 🔄 fix: URL Param Race Condition and File Draft Persistence by **@danny-avila** in [#7257](https://github.com/danny-avila/LibreChat/pull/7257)
|
||||||
|
- 🔄 fix: Assistants Endpoint & Minor Issues by **@danny-avila** in [#7274](https://github.com/danny-avila/LibreChat/pull/7274)
|
||||||
|
- 🔄 fix: Ollama Think Tag Edge Case with Tools by **@danny-avila** in [#7275](https://github.com/danny-avila/LibreChat/pull/7275)
|
||||||
|
|
||||||
|
### ⚙️ Other Changes
|
||||||
|
|
||||||
|
- 📜 docs: CHANGELOG for release v0.7.8-rc1 by **@github-actions[bot]** in [#7153](https://github.com/danny-avila/LibreChat/pull/7153)
|
||||||
|
- 🔄 refactor: Artifact Visibility Management by **@danny-avila** in [#7181](https://github.com/danny-avila/LibreChat/pull/7181)
|
||||||
|
- 📦 chore: Bump Package Security by **@danny-avila** in [#7183](https://github.com/danny-avila/LibreChat/pull/7183)
|
||||||
|
- 🌿 refactor: Unmount Fork Popover on Hide for Better Performance by **@danny-avila** in [#7189](https://github.com/danny-avila/LibreChat/pull/7189)
|
||||||
|
- 🧰 chore: ESLint configuration to enforce Prettier formatting rules by **@mawburn** in [#7186](https://github.com/danny-avila/LibreChat/pull/7186)
|
||||||
|
- 🎨 style: Improve KaTeX Rendering for LaTeX Equations by **@andresgit** in [#7223](https://github.com/danny-avila/LibreChat/pull/7223)
|
||||||
|
- 📝 docs: Update `.env.example` Google models by **@marlonka** in [#7254](https://github.com/danny-avila/LibreChat/pull/7254)
|
||||||
|
- 💬 refactor: MCP Chat Visibility Option, Google Rates, Remove OpenAPI Plugins by **@danny-avila** in [#7286](https://github.com/danny-avila/LibreChat/pull/7286)
|
||||||
|
- 📜 docs: Unreleased Changelog by **@github-actions[bot]** in [#7214](https://github.com/danny-avila/LibreChat/pull/7214)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[See full release details][release-v0.7.8]
|
||||||
|
|
||||||
|
[release-v0.7.8]: https://github.com/danny-avila/LibreChat/releases/tag/v0.7.8
|
||||||
|
|
||||||
|
---
|
||||||
|
## [v0.7.8-rc1] -
|
||||||
|
|
||||||
|
Changes from v0.7.7 to v0.7.8-rc1.
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
- 🔍 feat: Mistral OCR API / Upload Files as Text by **@danny-avila** in [#6274](https://github.com/danny-avila/LibreChat/pull/6274)
|
||||||
|
- 🤖 feat: Support OpenAI Web Search models by **@danny-avila** in [#6313](https://github.com/danny-avila/LibreChat/pull/6313)
|
||||||
|
- 🔗 feat: Agent Chain (Mixture-of-Agents) by **@danny-avila** in [#6374](https://github.com/danny-avila/LibreChat/pull/6374)
|
||||||
|
- ⌛ feat: `initTimeout` for Slow Starting MCP Servers by **@perweij** in [#6383](https://github.com/danny-avila/LibreChat/pull/6383)
|
||||||
|
- 🚀 feat: `S3` Integration for File handling and Image uploads by **@rubentalstra** in [#6142](https://github.com/danny-avila/LibreChat/pull/6142)
|
||||||
|
- 🔒feat: Enable OpenID Auto-Redirect by **@leondape** in [#6066](https://github.com/danny-avila/LibreChat/pull/6066)
|
||||||
|
- 🚀 feat: Integrate `Azure Blob Storage` for file handling and image uploads by **@rubentalstra** in [#6153](https://github.com/danny-avila/LibreChat/pull/6153)
|
||||||
|
- 🚀 feat: Add support for custom `AWS` endpoint in `S3` by **@rubentalstra** in [#6431](https://github.com/danny-avila/LibreChat/pull/6431)
|
||||||
|
- 🚀 feat: Add support for LDAP STARTTLS in LDAP authentication by **@rubentalstra** in [#6438](https://github.com/danny-avila/LibreChat/pull/6438)
|
||||||
|
- 🚀 feat: Refactor schema exports and update package version to 0.0.4 by **@rubentalstra** in [#6455](https://github.com/danny-avila/LibreChat/pull/6455)
|
||||||
|
- 🔼 feat: Add Auto Submit For URL Query Params by **@mjaverto** in [#6440](https://github.com/danny-avila/LibreChat/pull/6440)
|
||||||
|
- 🛠 feat: Enhance Redis Integration, Rate Limiters & Log Headers by **@danny-avila** in [#6462](https://github.com/danny-avila/LibreChat/pull/6462)
|
||||||
|
- 💵 feat: Add Automatic Balance Refill by **@rubentalstra** in [#6452](https://github.com/danny-avila/LibreChat/pull/6452)
|
||||||
|
- 🗣️ feat: add support for gpt-4o-transcribe models by **@berry-13** in [#6483](https://github.com/danny-avila/LibreChat/pull/6483)
|
||||||
|
- 🎨 feat: UI Refresh for Enhanced UX by **@berry-13** in [#6346](https://github.com/danny-avila/LibreChat/pull/6346)
|
||||||
|
- 🌍 feat: Add support for Hungarian language localization by **@rubentalstra** in [#6508](https://github.com/danny-avila/LibreChat/pull/6508)
|
||||||
|
- 🚀 feat: Add Gemini 2.5 Token/Context Values, Increase Max Possible Output to 64k by **@danny-avila** in [#6563](https://github.com/danny-avila/LibreChat/pull/6563)
|
||||||
|
- 🚀 feat: Enhance MCP Connections For Multi-User Support by **@danny-avila** in [#6610](https://github.com/danny-avila/LibreChat/pull/6610)
|
||||||
|
- 🚀 feat: Enhance S3 URL Expiry with Refresh; fix: S3 File Deletion by **@danny-avila** in [#6647](https://github.com/danny-avila/LibreChat/pull/6647)
|
||||||
|
- 🚀 feat: enhance UI components and refactor settings by **@berry-13** in [#6625](https://github.com/danny-avila/LibreChat/pull/6625)
|
||||||
|
- 💬 feat: move TemporaryChat to the Header by **@berry-13** in [#6646](https://github.com/danny-avila/LibreChat/pull/6646)
|
||||||
|
- 🚀 feat: Use Model Specs + Specific Endpoints, Limit Providers for Agents by **@danny-avila** in [#6650](https://github.com/danny-avila/LibreChat/pull/6650)
|
||||||
|
- 🪙 feat: Sync Balance Config on Login by **@danny-avila** in [#6671](https://github.com/danny-avila/LibreChat/pull/6671)
|
||||||
|
- 🔦 feat: MCP Support for Non-Agent Endpoints by **@danny-avila** in [#6775](https://github.com/danny-avila/LibreChat/pull/6775)
|
||||||
|
- 🗃️ feat: Code Interpreter File Persistence between Sessions by **@danny-avila** in [#6790](https://github.com/danny-avila/LibreChat/pull/6790)
|
||||||
|
- 🖥️ feat: Code Interpreter API for Non-Agent Endpoints by **@danny-avila** in [#6803](https://github.com/danny-avila/LibreChat/pull/6803)
|
||||||
|
- ⚡ feat: Self-hosted Artifacts Static Bundler URL by **@danny-avila** in [#6827](https://github.com/danny-avila/LibreChat/pull/6827)
|
||||||
|
- 🐳 feat: Add Jemalloc and UV to Docker Builds by **@danny-avila** in [#6836](https://github.com/danny-avila/LibreChat/pull/6836)
|
||||||
|
- 🤖 feat: GPT-4.1 by **@danny-avila** in [#6880](https://github.com/danny-avila/LibreChat/pull/6880)
|
||||||
|
- 👋 feat: remove Edge TTS by **@berry-13** in [#6885](https://github.com/danny-avila/LibreChat/pull/6885)
|
||||||
|
- feat: nav optimization by **@berry-13** in [#5785](https://github.com/danny-avila/LibreChat/pull/5785)
|
||||||
|
- 🗺️ feat: Add Parameter Location Mapping for OpenAPI actions by **@peeeteeer** in [#6858](https://github.com/danny-avila/LibreChat/pull/6858)
|
||||||
|
- 🤖 feat: Support `o4-mini` and `o3` Models by **@danny-avila** in [#6928](https://github.com/danny-avila/LibreChat/pull/6928)
|
||||||
|
- 🎨 feat: OpenAI Image Tools (GPT-Image-1) by **@danny-avila** in [#7079](https://github.com/danny-avila/LibreChat/pull/7079)
|
||||||
|
- 🗓️ feat: Add Special Variables for Prompts & Agents, Prompt UI Improvements by **@danny-avila** in [#7123](https://github.com/danny-avila/LibreChat/pull/7123)
|
||||||
|
|
||||||
|
### 🌍 Internationalization
|
||||||
|
|
||||||
|
- 🌍 i18n: Add Thai Language Support and Update Translations by **@rubentalstra** in [#6219](https://github.com/danny-avila/LibreChat/pull/6219)
|
||||||
|
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6220](https://github.com/danny-avila/LibreChat/pull/6220)
|
||||||
|
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6240](https://github.com/danny-avila/LibreChat/pull/6240)
|
||||||
|
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6241](https://github.com/danny-avila/LibreChat/pull/6241)
|
||||||
|
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6277](https://github.com/danny-avila/LibreChat/pull/6277)
|
||||||
|
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6414](https://github.com/danny-avila/LibreChat/pull/6414)
|
||||||
|
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6505](https://github.com/danny-avila/LibreChat/pull/6505)
|
||||||
|
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6530](https://github.com/danny-avila/LibreChat/pull/6530)
|
||||||
|
- 🌍 i18n: Add Persian Localization Support by **@rubentalstra** in [#6669](https://github.com/danny-avila/LibreChat/pull/6669)
|
||||||
|
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6667](https://github.com/danny-avila/LibreChat/pull/6667)
|
||||||
|
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7126](https://github.com/danny-avila/LibreChat/pull/7126)
|
||||||
|
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7148](https://github.com/danny-avila/LibreChat/pull/7148)
|
||||||
|
|
||||||
|
### 👐 Accessibility
|
||||||
|
|
||||||
|
- 🎨 a11y: Update Model Spec Description Text by **@berry-13** in [#6294](https://github.com/danny-avila/LibreChat/pull/6294)
|
||||||
|
- 🗑️ a11y: Add Accessible Name to Button for File Attachment Removal by **@kangabell** in [#6709](https://github.com/danny-avila/LibreChat/pull/6709)
|
||||||
|
- ⌨️ a11y: enhance accessibility & visual consistency by **@berry-13** in [#6866](https://github.com/danny-avila/LibreChat/pull/6866)
|
||||||
|
- 🙌 a11y: Searchbar/Conversations List Focus by **@danny-avila** in [#7096](https://github.com/danny-avila/LibreChat/pull/7096)
|
||||||
|
- 👐 a11y: Improve Fork and SplitText Accessibility by **@danny-avila** in [#7147](https://github.com/danny-avila/LibreChat/pull/7147)
|
||||||
|
|
||||||
|
### 🔧 Fixes
|
||||||
|
|
||||||
|
- 🐛 fix: Avatar Type Definitions in Agent/Assistant Schemas by **@danny-avila** in [#6235](https://github.com/danny-avila/LibreChat/pull/6235)
|
||||||
|
- 🔧 fix: MeiliSearch Field Error and Patch Incorrect Import by #6210 by **@rubentalstra** in [#6245](https://github.com/danny-avila/LibreChat/pull/6245)
|
||||||
|
- 🔏 fix: Enhance Two-Factor Authentication by **@rubentalstra** in [#6247](https://github.com/danny-avila/LibreChat/pull/6247)
|
||||||
|
- 🐛 fix: Await saveMessage in abortMiddleware to ensure proper execution by **@sh4shii** in [#6248](https://github.com/danny-avila/LibreChat/pull/6248)
|
||||||
|
- 🔧 fix: Axios Proxy Usage And Bump `mongoose` by **@danny-avila** in [#6298](https://github.com/danny-avila/LibreChat/pull/6298)
|
||||||
|
- 🔧 fix: comment out MCP servers to resolve service run issues by **@KunalScriptz** in [#6316](https://github.com/danny-avila/LibreChat/pull/6316)
|
||||||
|
- 🔧 fix: Update Token Calculations and Mapping, MCP `env` Initialization by **@danny-avila** in [#6406](https://github.com/danny-avila/LibreChat/pull/6406)
|
||||||
|
- 🐞 fix: Agent "Resend" Message Attachments + Source Icon Styling by **@danny-avila** in [#6408](https://github.com/danny-avila/LibreChat/pull/6408)
|
||||||
|
- 🐛 fix: Prevent Crash on Duplicate Message ID by **@Odrec** in [#6392](https://github.com/danny-avila/LibreChat/pull/6392)
|
||||||
|
- 🔐 fix: Invalid Key Length in 2FA Encryption by **@rubentalstra** in [#6432](https://github.com/danny-avila/LibreChat/pull/6432)
|
||||||
|
- 🏗️ fix: Fix Agents Token Spend Race Conditions, Expand Test Coverage by **@danny-avila** in [#6480](https://github.com/danny-avila/LibreChat/pull/6480)
|
||||||
|
- 🔃 fix: Draft Clearing, Claude Titles, Remove Default Vision Max Tokens by **@danny-avila** in [#6501](https://github.com/danny-avila/LibreChat/pull/6501)
|
||||||
|
- 🔧 fix: Update username reference to use user.name in greeting display by **@rubentalstra** in [#6534](https://github.com/danny-avila/LibreChat/pull/6534)
|
||||||
|
- 🔧 fix: S3 Download Stream with Key Extraction and Blob Storage Encoding for Vision by **@danny-avila** in [#6557](https://github.com/danny-avila/LibreChat/pull/6557)
|
||||||
|
- 🔧 fix: Mistral type strictness for `usage` & update token values/windows by **@danny-avila** in [#6562](https://github.com/danny-avila/LibreChat/pull/6562)
|
||||||
|
- 🔧 fix: Consolidate Text Parsing and TTS Edge Initialization by **@danny-avila** in [#6582](https://github.com/danny-avila/LibreChat/pull/6582)
|
||||||
|
- 🔧 fix: Ensure continuation in image processing on base64 encoding from Blob Storage by **@danny-avila** in [#6619](https://github.com/danny-avila/LibreChat/pull/6619)
|
||||||
|
- ✉️ fix: Fallback For User Name In Email Templates by **@danny-avila** in [#6620](https://github.com/danny-avila/LibreChat/pull/6620)
|
||||||
|
- 🔧 fix: Azure Blob Integration and File Source References by **@rubentalstra** in [#6575](https://github.com/danny-avila/LibreChat/pull/6575)
|
||||||
|
- 🐛 fix: Safeguard against undefined addedEndpoints by **@wipash** in [#6654](https://github.com/danny-avila/LibreChat/pull/6654)
|
||||||
|
- 🤖 fix: Gemini 2.5 Vision Support by **@danny-avila** in [#6663](https://github.com/danny-avila/LibreChat/pull/6663)
|
||||||
|
- 🔄 fix: Avatar & Error Handling Enhancements by **@danny-avila** in [#6687](https://github.com/danny-avila/LibreChat/pull/6687)
|
||||||
|
- 🔧 fix: Chat Middleware, Zod Conversion, Auto-Save and S3 URL Refresh by **@danny-avila** in [#6720](https://github.com/danny-avila/LibreChat/pull/6720)
|
||||||
|
- 🔧 fix: Agent Capability Checks & DocumentDB Compatibility for Agent Resource Removal by **@danny-avila** in [#6726](https://github.com/danny-avila/LibreChat/pull/6726)
|
||||||
|
- 🔄 fix: Improve audio MIME type detection and handling by **@berry-13** in [#6707](https://github.com/danny-avila/LibreChat/pull/6707)
|
||||||
|
- 🪺 fix: Update Role Handling due to New Schema Shape by **@danny-avila** in [#6774](https://github.com/danny-avila/LibreChat/pull/6774)
|
||||||
|
- 🗨️ fix: Show ModelSpec Greeting by **@berry-13** in [#6770](https://github.com/danny-avila/LibreChat/pull/6770)
|
||||||
|
- 🔧 fix: Keyv and Proxy Issues, and More Memory Optimizations by **@danny-avila** in [#6867](https://github.com/danny-avila/LibreChat/pull/6867)
|
||||||
|
- ✨ fix: Implement dynamic text sizing for greeting and name display by **@berry-13** in [#6833](https://github.com/danny-avila/LibreChat/pull/6833)
|
||||||
|
- 📝 fix: Mistral OCR Image Support and Azure Agent Titles by **@danny-avila** in [#6901](https://github.com/danny-avila/LibreChat/pull/6901)
|
||||||
|
- 📢 fix: Invalid `engineTTS` and Conversation State on Navigation by **@berry-13** in [#6904](https://github.com/danny-avila/LibreChat/pull/6904)
|
||||||
|
- 🛠️ fix: Improve Accessibility and Display of Conversation Menu by **@danny-avila** in [#6913](https://github.com/danny-avila/LibreChat/pull/6913)
|
||||||
|
- 🔧 fix: Agent Resource Form, Convo Menu Style, Ensure Draft Clears on Submission by **@danny-avila** in [#6925](https://github.com/danny-avila/LibreChat/pull/6925)
|
||||||
|
- 🔀 fix: MCP Improvements, Auto-Save Drafts, Artifact Markup by **@danny-avila** in [#7040](https://github.com/danny-avila/LibreChat/pull/7040)
|
||||||
|
- 🐋 fix: Improve Deepseek Compatbility by **@danny-avila** in [#7132](https://github.com/danny-avila/LibreChat/pull/7132)
|
||||||
|
- 🐙 fix: Add Redis Ping Interval to Prevent Connection Drops by **@peeeteeer** in [#7127](https://github.com/danny-avila/LibreChat/pull/7127)
|
||||||
|
|
||||||
|
### ⚙️ Other Changes
|
||||||
|
|
||||||
|
- 📦 refactor: Move DB Models to `@librechat/data-schemas` by **@rubentalstra** in [#6210](https://github.com/danny-avila/LibreChat/pull/6210)
|
||||||
|
- 📦 chore: Patch `axios` to address CVE-2025-27152 by **@danny-avila** in [#6222](https://github.com/danny-avila/LibreChat/pull/6222)
|
||||||
|
- ⚠️ refactor: Use Error Content Part Instead Of Throwing Error for Agents by **@danny-avila** in [#6262](https://github.com/danny-avila/LibreChat/pull/6262)
|
||||||
|
- 🏃♂️ refactor: Improve Agent Run Context & Misc. Changes by **@danny-avila** in [#6448](https://github.com/danny-avila/LibreChat/pull/6448)
|
||||||
|
- 📝 docs: librechat.example.yaml by **@ineiti** in [#6442](https://github.com/danny-avila/LibreChat/pull/6442)
|
||||||
|
- 🏃♂️ refactor: More Agent Context Improvements during Run by **@danny-avila** in [#6477](https://github.com/danny-avila/LibreChat/pull/6477)
|
||||||
|
- 🔃 refactor: Allow streaming for `o1` models by **@danny-avila** in [#6509](https://github.com/danny-avila/LibreChat/pull/6509)
|
||||||
|
- 🔧 chore: `Vite` Plugin Upgrades & Config Optimizations by **@rubentalstra** in [#6547](https://github.com/danny-avila/LibreChat/pull/6547)
|
||||||
|
- 🔧 refactor: Consolidate Logging, Model Selection & Actions Optimizations, Minor Fixes by **@danny-avila** in [#6553](https://github.com/danny-avila/LibreChat/pull/6553)
|
||||||
|
- 🎨 style: Address Minor UI Refresh Issues by **@berry-13** in [#6552](https://github.com/danny-avila/LibreChat/pull/6552)
|
||||||
|
- 🔧 refactor: Enhance Model & Endpoint Configurations with Global Indicators 🌍 by **@berry-13** in [#6578](https://github.com/danny-avila/LibreChat/pull/6578)
|
||||||
|
- 💬 style: Chat UI, Greeting, and Message adjustments by **@berry-13** in [#6612](https://github.com/danny-avila/LibreChat/pull/6612)
|
||||||
|
- ⚡ refactor: DocumentDB Compatibility for Balance Updates by **@danny-avila** in [#6673](https://github.com/danny-avila/LibreChat/pull/6673)
|
||||||
|
- 🧹 chore: Update ESLint rules for React hooks by **@rubentalstra** in [#6685](https://github.com/danny-avila/LibreChat/pull/6685)
|
||||||
|
- 🪙 chore: Update Gemini Pricing by **@RedwindA** in [#6731](https://github.com/danny-avila/LibreChat/pull/6731)
|
||||||
|
- 🪺 refactor: Nest Permission fields for Roles by **@rubentalstra** in [#6487](https://github.com/danny-avila/LibreChat/pull/6487)
|
||||||
|
- 📦 chore: Update `caniuse-lite` dependency to version 1.0.30001706 by **@rubentalstra** in [#6482](https://github.com/danny-avila/LibreChat/pull/6482)
|
||||||
|
- ⚙️ refactor: OAuth Flow Signal, Type Safety, Tool Progress & Updated Packages by **@danny-avila** in [#6752](https://github.com/danny-avila/LibreChat/pull/6752)
|
||||||
|
- 📦 chore: bump vite from 6.2.3 to 6.2.5 by **@dependabot[bot]** in [#6745](https://github.com/danny-avila/LibreChat/pull/6745)
|
||||||
|
- 💾 chore: Enhance Local Storage Handling and Update MCP SDK by **@danny-avila** in [#6809](https://github.com/danny-avila/LibreChat/pull/6809)
|
||||||
|
- 🤖 refactor: Improve Agents Memory Usage, Bump Keyv, Grok 3 by **@danny-avila** in [#6850](https://github.com/danny-avila/LibreChat/pull/6850)
|
||||||
|
- 💾 refactor: Enhance Memory In Image Encodings & Client Disposal by **@danny-avila** in [#6852](https://github.com/danny-avila/LibreChat/pull/6852)
|
||||||
|
- 🔁 refactor: Token Event Handler and Standardize `maxTokens` Key by **@danny-avila** in [#6886](https://github.com/danny-avila/LibreChat/pull/6886)
|
||||||
|
- 🔍 refactor: Search & Message Retrieval by **@berry-13** in [#6903](https://github.com/danny-avila/LibreChat/pull/6903)
|
||||||
|
- 🎨 style: standardize dropdown styling & fix z-Index layering by **@berry-13** in [#6939](https://github.com/danny-avila/LibreChat/pull/6939)
|
||||||
|
- 📙 docs: CONTRIBUTING.md by **@dblock** in [#6831](https://github.com/danny-avila/LibreChat/pull/6831)
|
||||||
|
- 🧭 refactor: Modernize Nav/Header by **@danny-avila** in [#7094](https://github.com/danny-avila/LibreChat/pull/7094)
|
||||||
|
- 🪶 refactor: Chat Input Focus for Conversation Navigations & ChatForm Optimizations by **@danny-avila** in [#7100](https://github.com/danny-avila/LibreChat/pull/7100)
|
||||||
|
- 🔃 refactor: Streamline Navigation, Message Loading UX by **@danny-avila** in [#7118](https://github.com/danny-avila/LibreChat/pull/7118)
|
||||||
|
- 📜 docs: Unreleased changelog by **@github-actions[bot]** in [#6265](https://github.com/danny-avila/LibreChat/pull/6265)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[See full release details][release-v0.7.8-rc1]
|
||||||
|
|
||||||
|
[release-v0.7.8-rc1]: https://github.com/danny-avila/LibreChat/releases/tag/v0.7.8-rc1
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
15
Dockerfile
15
Dockerfile
@@ -1,9 +1,18 @@
|
|||||||
# v0.7.7
|
# v0.7.8
|
||||||
|
|
||||||
# Base node image
|
# Base node image
|
||||||
FROM node:20-alpine AS node
|
FROM node:20-alpine AS node
|
||||||
|
|
||||||
RUN apk --no-cache add curl
|
# Install jemalloc
|
||||||
|
RUN apk add --no-cache jemalloc
|
||||||
|
RUN apk add --no-cache python3 py3-pip uv
|
||||||
|
|
||||||
|
# Set environment variable to use jemalloc
|
||||||
|
ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2
|
||||||
|
|
||||||
|
# Add `uv` for extended MCP support
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.6.13 /uv /uvx /bin/
|
||||||
|
RUN uv --version
|
||||||
|
|
||||||
RUN mkdir -p /app && chown node:node /app
|
RUN mkdir -p /app && chown node:node /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -38,4 +47,4 @@ CMD ["npm", "run", "backend"]
|
|||||||
# WORKDIR /usr/share/nginx/html
|
# WORKDIR /usr/share/nginx/html
|
||||||
# COPY --from=node /app/client/dist /usr/share/nginx/html
|
# COPY --from=node /app/client/dist /usr/share/nginx/html
|
||||||
# COPY client/nginx.conf /etc/nginx/conf.d/default.conf
|
# COPY client/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
# ENTRYPOINT ["nginx", "-g", "daemon off;"]
|
# ENTRYPOINT ["nginx", "-g", "daemon off;"]
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
# Dockerfile.multi
|
# Dockerfile.multi
|
||||||
# v0.7.7
|
# v0.7.8
|
||||||
|
|
||||||
# Base for all builds
|
# Base for all builds
|
||||||
FROM node:20-alpine AS base-min
|
FROM node:20-alpine AS base-min
|
||||||
|
# Install jemalloc
|
||||||
|
RUN apk add --no-cache jemalloc
|
||||||
|
# Set environment variable to use jemalloc
|
||||||
|
ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk --no-cache add curl
|
RUN apk --no-cache add curl
|
||||||
RUN npm config set fetch-retry-maxtimeout 600000 && \
|
RUN npm config set fetch-retry-maxtimeout 600000 && \
|
||||||
@@ -50,6 +54,9 @@ RUN npm run build
|
|||||||
|
|
||||||
# API setup (including client dist)
|
# API setup (including client dist)
|
||||||
FROM base-min AS api-build
|
FROM base-min AS api-build
|
||||||
|
# Add `uv` for extended MCP support
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.6.13 /uv /uvx /bin/
|
||||||
|
RUN uv --version
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# Install only production deps
|
# Install only production deps
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|||||||
@@ -74,6 +74,11 @@
|
|||||||
- 🪄 **Generative UI with Code Artifacts**:
|
- 🪄 **Generative UI with Code Artifacts**:
|
||||||
- [Code Artifacts](https://youtu.be/GfTj7O4gmd0?si=WJbdnemZpJzBrJo3) allow creation of React, HTML, and Mermaid diagrams directly in chat
|
- [Code Artifacts](https://youtu.be/GfTj7O4gmd0?si=WJbdnemZpJzBrJo3) allow creation of React, HTML, and Mermaid diagrams directly in chat
|
||||||
|
|
||||||
|
- 🎨 **Image Generation & Editing**
|
||||||
|
- Text-to-image and image-to-image with [GPT-Image-1](https://www.librechat.ai/docs/features/image_gen#1--openai-image-tools-recommended)
|
||||||
|
- Text-to-image with [DALL-E (3/2)](https://www.librechat.ai/docs/features/image_gen#2--dalle-legacy), [Stable Diffusion](https://www.librechat.ai/docs/features/image_gen#3--stable-diffusion-local), [Flux](https://www.librechat.ai/docs/features/image_gen#4--flux), or any [MCP server](https://www.librechat.ai/docs/features/image_gen#5--model-context-protocol-mcp)
|
||||||
|
- Produce stunning visuals from prompts or refine existing images with a single instruction
|
||||||
|
|
||||||
- 💾 **Presets & Context Management**:
|
- 💾 **Presets & Context Management**:
|
||||||
- Create, Save, & Share Custom Presets
|
- Create, Save, & Share Custom Presets
|
||||||
- Switch between AI Endpoints and Presets mid-chat
|
- Switch between AI Endpoints and Presets mid-chat
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ const {
|
|||||||
Constants,
|
Constants,
|
||||||
ErrorTypes,
|
ErrorTypes,
|
||||||
EModelEndpoint,
|
EModelEndpoint,
|
||||||
|
parseTextParts,
|
||||||
anthropicSettings,
|
anthropicSettings,
|
||||||
getResponseSender,
|
getResponseSender,
|
||||||
validateVisionModel,
|
validateVisionModel,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { SplitStreamHandler: _Handler, GraphEvents } = require('@librechat/agents');
|
const { SplitStreamHandler: _Handler } = require('@librechat/agents');
|
||||||
const {
|
const {
|
||||||
truncateText,
|
truncateText,
|
||||||
formatMessage,
|
formatMessage,
|
||||||
@@ -25,10 +26,11 @@ const {
|
|||||||
const { getModelMaxTokens, getModelMaxOutputTokens, matchModelName } = require('~/utils');
|
const { getModelMaxTokens, getModelMaxOutputTokens, matchModelName } = require('~/utils');
|
||||||
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
||||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||||
|
const { createFetch, createStreamEventHandlers } = require('./generators');
|
||||||
const Tokenizer = require('~/server/services/Tokenizer');
|
const Tokenizer = require('~/server/services/Tokenizer');
|
||||||
const { logger, sendEvent } = require('~/config');
|
|
||||||
const { sleep } = require('~/server/utils');
|
const { sleep } = require('~/server/utils');
|
||||||
const BaseClient = require('./BaseClient');
|
const BaseClient = require('./BaseClient');
|
||||||
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
const HUMAN_PROMPT = '\n\nHuman:';
|
const HUMAN_PROMPT = '\n\nHuman:';
|
||||||
const AI_PROMPT = '\n\nAssistant:';
|
const AI_PROMPT = '\n\nAssistant:';
|
||||||
@@ -183,7 +185,10 @@ class AnthropicClient extends BaseClient {
|
|||||||
getClient(requestOptions) {
|
getClient(requestOptions) {
|
||||||
/** @type {Anthropic.ClientOptions} */
|
/** @type {Anthropic.ClientOptions} */
|
||||||
const options = {
|
const options = {
|
||||||
fetch: this.fetch,
|
fetch: createFetch({
|
||||||
|
directEndpoint: this.options.directEndpoint,
|
||||||
|
reverseProxyUrl: this.options.reverseProxyUrl,
|
||||||
|
}),
|
||||||
apiKey: this.apiKey,
|
apiKey: this.apiKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -391,13 +396,13 @@ class AnthropicClient extends BaseClient {
|
|||||||
const formattedMessages = orderedMessages.map((message, i) => {
|
const formattedMessages = orderedMessages.map((message, i) => {
|
||||||
const formattedMessage = this.useMessages
|
const formattedMessage = this.useMessages
|
||||||
? formatMessage({
|
? formatMessage({
|
||||||
message,
|
message,
|
||||||
endpoint: EModelEndpoint.anthropic,
|
endpoint: EModelEndpoint.anthropic,
|
||||||
})
|
})
|
||||||
: {
|
: {
|
||||||
author: message.isCreatedByUser ? this.userLabel : this.assistantLabel,
|
author: message.isCreatedByUser ? this.userLabel : this.assistantLabel,
|
||||||
content: message?.content ?? message.text,
|
content: message?.content ?? message.text,
|
||||||
};
|
};
|
||||||
|
|
||||||
const needsTokenCount = this.contextStrategy && !orderedMessages[i].tokenCount;
|
const needsTokenCount = this.contextStrategy && !orderedMessages[i].tokenCount;
|
||||||
/* If tokens were never counted, or, is a Vision request and the message has files, count again */
|
/* If tokens were never counted, or, is a Vision request and the message has files, count again */
|
||||||
@@ -413,6 +418,9 @@ class AnthropicClient extends BaseClient {
|
|||||||
this.contextHandlers?.processFile(file);
|
this.contextHandlers?.processFile(file);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (file.metadata?.fileIdentifier) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
orderedMessages[i].tokenCount += this.calculateImageTokenCost({
|
orderedMessages[i].tokenCount += this.calculateImageTokenCost({
|
||||||
width: file.width,
|
width: file.width,
|
||||||
@@ -672,7 +680,7 @@ class AnthropicClient extends BaseClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getCompletion() {
|
getCompletion() {
|
||||||
logger.debug('AnthropicClient doesn\'t use getCompletion (all handled in sendCompletion)');
|
logger.debug("AnthropicClient doesn't use getCompletion (all handled in sendCompletion)");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -696,15 +704,8 @@ class AnthropicClient extends BaseClient {
|
|||||||
if (msg.text != null && msg.text && msg.text.startsWith(':::thinking')) {
|
if (msg.text != null && msg.text && msg.text.startsWith(':::thinking')) {
|
||||||
msg.text = msg.text.replace(/:::thinking.*?:::/gs, '').trim();
|
msg.text = msg.text.replace(/:::thinking.*?:::/gs, '').trim();
|
||||||
} else if (msg.content != null) {
|
} else if (msg.content != null) {
|
||||||
/** @type {import('@librechat/agents').MessageContentComplex} */
|
msg.text = parseTextParts(msg.content, true);
|
||||||
const newContent = [];
|
delete msg.content;
|
||||||
for (let part of msg.content) {
|
|
||||||
if (part.think != null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
newContent.push(part);
|
|
||||||
}
|
|
||||||
msg.content = newContent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return msg;
|
return msg;
|
||||||
@@ -801,14 +802,11 @@ class AnthropicClient extends BaseClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('[AnthropicClient]', { ...requestOptions });
|
logger.debug('[AnthropicClient]', { ...requestOptions });
|
||||||
|
const handlers = createStreamEventHandlers(this.options.res);
|
||||||
this.streamHandler = new SplitStreamHandler({
|
this.streamHandler = new SplitStreamHandler({
|
||||||
accumulate: true,
|
accumulate: true,
|
||||||
runId: this.responseMessageId,
|
runId: this.responseMessageId,
|
||||||
handlers: {
|
handlers,
|
||||||
[GraphEvents.ON_RUN_STEP]: (event) => sendEvent(this.options.res, event),
|
|
||||||
[GraphEvents.ON_MESSAGE_DELTA]: (event) => sendEvent(this.options.res, event),
|
|
||||||
[GraphEvents.ON_REASONING_DELTA]: (event) => sendEvent(this.options.res, event),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let intermediateReply = this.streamHandler.tokens;
|
let intermediateReply = this.streamHandler.tokens;
|
||||||
@@ -890,7 +888,7 @@ class AnthropicClient extends BaseClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getBuildMessagesOptions() {
|
getBuildMessagesOptions() {
|
||||||
logger.debug('AnthropicClient doesn\'t use getBuildMessagesOptions');
|
logger.debug("AnthropicClient doesn't use getBuildMessagesOptions");
|
||||||
}
|
}
|
||||||
|
|
||||||
getEncoding() {
|
getEncoding() {
|
||||||
|
|||||||
@@ -28,15 +28,10 @@ class BaseClient {
|
|||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
});
|
});
|
||||||
this.fetch = this.fetch.bind(this);
|
|
||||||
/** @type {boolean} */
|
/** @type {boolean} */
|
||||||
this.skipSaveConvo = false;
|
this.skipSaveConvo = false;
|
||||||
/** @type {boolean} */
|
/** @type {boolean} */
|
||||||
this.skipSaveUserMessage = false;
|
this.skipSaveUserMessage = false;
|
||||||
/** @type {ClientDatabaseSavePromise} */
|
|
||||||
this.userMessagePromise;
|
|
||||||
/** @type {ClientDatabaseSavePromise} */
|
|
||||||
this.responsePromise;
|
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
this.user;
|
this.user;
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
@@ -68,15 +63,15 @@ class BaseClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setOptions() {
|
setOptions() {
|
||||||
throw new Error('Method \'setOptions\' must be implemented.');
|
throw new Error("Method 'setOptions' must be implemented.");
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCompletion() {
|
async getCompletion() {
|
||||||
throw new Error('Method \'getCompletion\' must be implemented.');
|
throw new Error("Method 'getCompletion' must be implemented.");
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendCompletion() {
|
async sendCompletion() {
|
||||||
throw new Error('Method \'sendCompletion\' must be implemented.');
|
throw new Error("Method 'sendCompletion' must be implemented.");
|
||||||
}
|
}
|
||||||
|
|
||||||
getSaveOptions() {
|
getSaveOptions() {
|
||||||
@@ -242,11 +237,11 @@ class BaseClient {
|
|||||||
const userMessage = opts.isEdited
|
const userMessage = opts.isEdited
|
||||||
? this.currentMessages[this.currentMessages.length - 2]
|
? this.currentMessages[this.currentMessages.length - 2]
|
||||||
: this.createUserMessage({
|
: this.createUserMessage({
|
||||||
messageId: userMessageId,
|
messageId: userMessageId,
|
||||||
parentMessageId,
|
parentMessageId,
|
||||||
conversationId,
|
conversationId,
|
||||||
text: message,
|
text: message,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (typeof opts?.getReqData === 'function') {
|
if (typeof opts?.getReqData === 'function') {
|
||||||
opts.getReqData({
|
opts.getReqData({
|
||||||
@@ -564,6 +559,8 @@ class BaseClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(message, opts = {}) {
|
async sendMessage(message, opts = {}) {
|
||||||
|
/** @type {Promise<TMessage>} */
|
||||||
|
let userMessagePromise;
|
||||||
const { user, head, isEdited, conversationId, responseMessageId, saveOptions, userMessage } =
|
const { user, head, isEdited, conversationId, responseMessageId, saveOptions, userMessage } =
|
||||||
await this.handleStartMethods(message, opts);
|
await this.handleStartMethods(message, opts);
|
||||||
|
|
||||||
@@ -625,11 +622,11 @@ class BaseClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isEdited && !this.skipSaveUserMessage) {
|
if (!isEdited && !this.skipSaveUserMessage) {
|
||||||
this.userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user);
|
userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user);
|
||||||
this.savedMessageIds.add(userMessage.messageId);
|
this.savedMessageIds.add(userMessage.messageId);
|
||||||
if (typeof opts?.getReqData === 'function') {
|
if (typeof opts?.getReqData === 'function') {
|
||||||
opts.getReqData({
|
opts.getReqData({
|
||||||
userMessagePromise: this.userMessagePromise,
|
userMessagePromise,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -655,7 +652,9 @@ class BaseClient {
|
|||||||
|
|
||||||
/** @type {string|string[]|undefined} */
|
/** @type {string|string[]|undefined} */
|
||||||
const completion = await this.sendCompletion(payload, opts);
|
const completion = await this.sendCompletion(payload, opts);
|
||||||
this.abortController.requestCompleted = true;
|
if (this.abortController) {
|
||||||
|
this.abortController.requestCompleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {TMessage} */
|
/** @type {TMessage} */
|
||||||
const responseMessage = {
|
const responseMessage = {
|
||||||
@@ -676,7 +675,8 @@ class BaseClient {
|
|||||||
responseMessage.text = addSpaceIfNeeded(generation) + completion;
|
responseMessage.text = addSpaceIfNeeded(generation) + completion;
|
||||||
} else if (
|
} else if (
|
||||||
Array.isArray(completion) &&
|
Array.isArray(completion) &&
|
||||||
isParamEndpoint(this.options.endpoint, this.options.endpointType)
|
(this.clientName === EModelEndpoint.agents ||
|
||||||
|
isParamEndpoint(this.options.endpoint, this.options.endpointType))
|
||||||
) {
|
) {
|
||||||
responseMessage.text = '';
|
responseMessage.text = '';
|
||||||
responseMessage.content = completion;
|
responseMessage.content = completion;
|
||||||
@@ -702,7 +702,13 @@ class BaseClient {
|
|||||||
if (usage != null && Number(usage[this.outputTokensKey]) > 0) {
|
if (usage != null && Number(usage[this.outputTokensKey]) > 0) {
|
||||||
responseMessage.tokenCount = usage[this.outputTokensKey];
|
responseMessage.tokenCount = usage[this.outputTokensKey];
|
||||||
completionTokens = responseMessage.tokenCount;
|
completionTokens = responseMessage.tokenCount;
|
||||||
await this.updateUserMessageTokenCount({ usage, tokenCountMap, userMessage, opts });
|
await this.updateUserMessageTokenCount({
|
||||||
|
usage,
|
||||||
|
tokenCountMap,
|
||||||
|
userMessage,
|
||||||
|
userMessagePromise,
|
||||||
|
opts,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
responseMessage.tokenCount = this.getTokenCountForResponse(responseMessage);
|
responseMessage.tokenCount = this.getTokenCountForResponse(responseMessage);
|
||||||
completionTokens = responseMessage.tokenCount;
|
completionTokens = responseMessage.tokenCount;
|
||||||
@@ -711,8 +717,8 @@ class BaseClient {
|
|||||||
await this.recordTokenUsage({ promptTokens, completionTokens, usage });
|
await this.recordTokenUsage({ promptTokens, completionTokens, usage });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.userMessagePromise) {
|
if (userMessagePromise) {
|
||||||
await this.userMessagePromise;
|
await userMessagePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.artifactPromises) {
|
if (this.artifactPromises) {
|
||||||
@@ -727,7 +733,11 @@ class BaseClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.responsePromise = this.saveMessageToDatabase(responseMessage, saveOptions, user);
|
responseMessage.databasePromise = this.saveMessageToDatabase(
|
||||||
|
responseMessage,
|
||||||
|
saveOptions,
|
||||||
|
user,
|
||||||
|
);
|
||||||
this.savedMessageIds.add(responseMessage.messageId);
|
this.savedMessageIds.add(responseMessage.messageId);
|
||||||
delete responseMessage.tokenCount;
|
delete responseMessage.tokenCount;
|
||||||
return responseMessage;
|
return responseMessage;
|
||||||
@@ -748,9 +758,16 @@ class BaseClient {
|
|||||||
* @param {StreamUsage} params.usage
|
* @param {StreamUsage} params.usage
|
||||||
* @param {Record<string, number>} params.tokenCountMap
|
* @param {Record<string, number>} params.tokenCountMap
|
||||||
* @param {TMessage} params.userMessage
|
* @param {TMessage} params.userMessage
|
||||||
|
* @param {Promise<TMessage>} params.userMessagePromise
|
||||||
* @param {object} params.opts
|
* @param {object} params.opts
|
||||||
*/
|
*/
|
||||||
async updateUserMessageTokenCount({ usage, tokenCountMap, userMessage, opts }) {
|
async updateUserMessageTokenCount({
|
||||||
|
usage,
|
||||||
|
tokenCountMap,
|
||||||
|
userMessage,
|
||||||
|
userMessagePromise,
|
||||||
|
opts,
|
||||||
|
}) {
|
||||||
/** @type {boolean} */
|
/** @type {boolean} */
|
||||||
const shouldUpdateCount =
|
const shouldUpdateCount =
|
||||||
this.calculateCurrentTokenCount != null &&
|
this.calculateCurrentTokenCount != null &&
|
||||||
@@ -786,7 +803,7 @@ class BaseClient {
|
|||||||
Note: we update the user message to be sure it gets the calculated token count;
|
Note: we update the user message to be sure it gets the calculated token count;
|
||||||
though `AskController` saves the user message, EditController does not
|
though `AskController` saves the user message, EditController does not
|
||||||
*/
|
*/
|
||||||
await this.userMessagePromise;
|
await userMessagePromise;
|
||||||
await this.updateMessageInDatabase({
|
await this.updateMessageInDatabase({
|
||||||
messageId: userMessage.messageId,
|
messageId: userMessage.messageId,
|
||||||
tokenCount: userMessageTokenCount,
|
tokenCount: userMessageTokenCount,
|
||||||
@@ -852,7 +869,7 @@ class BaseClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const savedMessage = await saveMessage(
|
const savedMessage = await saveMessage(
|
||||||
this.options.req,
|
this.options?.req,
|
||||||
{
|
{
|
||||||
...message,
|
...message,
|
||||||
endpoint: this.options.endpoint,
|
endpoint: this.options.endpoint,
|
||||||
@@ -876,7 +893,7 @@ class BaseClient {
|
|||||||
const existingConvo =
|
const existingConvo =
|
||||||
this.fetchedConvo === true
|
this.fetchedConvo === true
|
||||||
? null
|
? null
|
||||||
: await getConvo(this.options.req?.user?.id, message.conversationId);
|
: await getConvo(this.options?.req?.user?.id, message.conversationId);
|
||||||
|
|
||||||
const unsetFields = {};
|
const unsetFields = {};
|
||||||
const exceptions = new Set(['spec', 'iconURL']);
|
const exceptions = new Set(['spec', 'iconURL']);
|
||||||
@@ -896,7 +913,7 @@ class BaseClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const conversation = await saveConvo(this.options.req, fieldsToKeep, {
|
const conversation = await saveConvo(this.options?.req, fieldsToKeep, {
|
||||||
context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveConvo',
|
context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveConvo',
|
||||||
unsetFields,
|
unsetFields,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const Keyv = require('keyv');
|
const { Keyv } = require('keyv');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { CohereClient } = require('cohere-ai');
|
const { CohereClient } = require('cohere-ai');
|
||||||
const { fetchEventSource } = require('@waylaidwanderer/fetch-event-source');
|
const { fetchEventSource } = require('@waylaidwanderer/fetch-event-source');
|
||||||
@@ -339,7 +339,7 @@ class ChatGPTClient extends BaseClient {
|
|||||||
opts.body = JSON.stringify(modelOptions);
|
opts.body = JSON.stringify(modelOptions);
|
||||||
|
|
||||||
if (modelOptions.stream) {
|
if (modelOptions.stream) {
|
||||||
// eslint-disable-next-line no-async-promise-executor
|
|
||||||
return new Promise(async (resolve, reject) => {
|
return new Promise(async (resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
let done = false;
|
let done = false;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const {
|
|||||||
validateVisionModel,
|
validateVisionModel,
|
||||||
getResponseSender,
|
getResponseSender,
|
||||||
endpointSettings,
|
endpointSettings,
|
||||||
|
parseTextParts,
|
||||||
EModelEndpoint,
|
EModelEndpoint,
|
||||||
ContentTypes,
|
ContentTypes,
|
||||||
VisionModes,
|
VisionModes,
|
||||||
@@ -139,8 +140,7 @@ class GoogleClient extends BaseClient {
|
|||||||
this.options.attachments?.then((attachments) => this.checkVisionRequest(attachments));
|
this.options.attachments?.then((attachments) => this.checkVisionRequest(attachments));
|
||||||
|
|
||||||
/** @type {boolean} Whether using a "GenerativeAI" Model */
|
/** @type {boolean} Whether using a "GenerativeAI" Model */
|
||||||
this.isGenerativeModel =
|
this.isGenerativeModel = /gemini|learnlm|gemma/.test(this.modelOptions.model);
|
||||||
this.modelOptions.model.includes('gemini') || this.modelOptions.model.includes('learnlm');
|
|
||||||
|
|
||||||
this.maxContextTokens =
|
this.maxContextTokens =
|
||||||
this.options.maxContextTokens ??
|
this.options.maxContextTokens ??
|
||||||
@@ -317,6 +317,9 @@ class GoogleClient extends BaseClient {
|
|||||||
this.contextHandlers?.processFile(file);
|
this.contextHandlers?.processFile(file);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (file.metadata?.fileIdentifier) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.augmentedPrompt = await this.contextHandlers.createContext();
|
this.augmentedPrompt = await this.contextHandlers.createContext();
|
||||||
@@ -774,6 +777,22 @@ class GoogleClient extends BaseClient {
|
|||||||
return this.usage;
|
return this.usage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMessageMapMethod() {
|
||||||
|
/**
|
||||||
|
* @param {TMessage} msg
|
||||||
|
*/
|
||||||
|
return (msg) => {
|
||||||
|
if (msg.text != null && msg.text && msg.text.startsWith(':::thinking')) {
|
||||||
|
msg.text = msg.text.replace(/:::thinking.*?:::/gs, '').trim();
|
||||||
|
} else if (msg.content != null) {
|
||||||
|
msg.text = parseTextParts(msg.content, true);
|
||||||
|
delete msg.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the correct token count for the current user message based on the token count map and API usage.
|
* Calculates the correct token count for the current user message based on the token count map and API usage.
|
||||||
* Edge case: If the calculation results in a negative value, it returns the original estimate.
|
* Edge case: If the calculation results in a negative value, it returns the original estimate.
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
const OpenAI = require('openai');
|
|
||||||
const { OllamaClient } = require('./OllamaClient');
|
const { OllamaClient } = require('./OllamaClient');
|
||||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||||
const { SplitStreamHandler, GraphEvents } = require('@librechat/agents');
|
const { SplitStreamHandler, CustomOpenAIClient: OpenAI } = require('@librechat/agents');
|
||||||
const {
|
const {
|
||||||
Constants,
|
Constants,
|
||||||
ImageDetail,
|
ImageDetail,
|
||||||
ContentTypes,
|
ContentTypes,
|
||||||
|
parseTextParts,
|
||||||
EModelEndpoint,
|
EModelEndpoint,
|
||||||
resolveHeaders,
|
resolveHeaders,
|
||||||
KnownEndpoints,
|
KnownEndpoints,
|
||||||
@@ -31,17 +31,18 @@ const {
|
|||||||
createContextHandlers,
|
createContextHandlers,
|
||||||
} = require('./prompts');
|
} = require('./prompts');
|
||||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||||
|
const { createFetch, createStreamEventHandlers } = require('./generators');
|
||||||
const { addSpaceIfNeeded, isEnabled, sleep } = require('~/server/utils');
|
const { addSpaceIfNeeded, isEnabled, sleep } = require('~/server/utils');
|
||||||
const Tokenizer = require('~/server/services/Tokenizer');
|
const Tokenizer = require('~/server/services/Tokenizer');
|
||||||
const { spendTokens } = require('~/models/spendTokens');
|
const { spendTokens } = require('~/models/spendTokens');
|
||||||
const { handleOpenAIErrors } = require('./tools/util');
|
const { handleOpenAIErrors } = require('./tools/util');
|
||||||
const { createLLM, RunManager } = require('./llm');
|
const { createLLM, RunManager } = require('./llm');
|
||||||
const { logger, sendEvent } = require('~/config');
|
|
||||||
const ChatGPTClient = require('./ChatGPTClient');
|
const ChatGPTClient = require('./ChatGPTClient');
|
||||||
const { summaryBuffer } = require('./memory');
|
const { summaryBuffer } = require('./memory');
|
||||||
const { runTitleChain } = require('./chains');
|
const { runTitleChain } = require('./chains');
|
||||||
const { tokenSplit } = require('./document');
|
const { tokenSplit } = require('./document');
|
||||||
const BaseClient = require('./BaseClient');
|
const BaseClient = require('./BaseClient');
|
||||||
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
class OpenAIClient extends BaseClient {
|
class OpenAIClient extends BaseClient {
|
||||||
constructor(apiKey, options = {}) {
|
constructor(apiKey, options = {}) {
|
||||||
@@ -107,7 +108,7 @@ class OpenAIClient extends BaseClient {
|
|||||||
this.checkVisionRequest(this.options.attachments);
|
this.checkVisionRequest(this.options.attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
const omniPattern = /\b(o1|o3)\b/i;
|
const omniPattern = /\b(o\d)\b/i;
|
||||||
this.isOmni = omniPattern.test(this.modelOptions.model);
|
this.isOmni = omniPattern.test(this.modelOptions.model);
|
||||||
|
|
||||||
const { OPENAI_FORCE_PROMPT } = process.env ?? {};
|
const { OPENAI_FORCE_PROMPT } = process.env ?? {};
|
||||||
@@ -454,6 +455,9 @@ class OpenAIClient extends BaseClient {
|
|||||||
this.contextHandlers?.processFile(file);
|
this.contextHandlers?.processFile(file);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (file.metadata?.fileIdentifier) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
orderedMessages[i].tokenCount += this.calculateImageTokenCost({
|
orderedMessages[i].tokenCount += this.calculateImageTokenCost({
|
||||||
width: file.width,
|
width: file.width,
|
||||||
@@ -471,7 +475,9 @@ class OpenAIClient extends BaseClient {
|
|||||||
promptPrefix = this.augmentedPrompt + promptPrefix;
|
promptPrefix = this.augmentedPrompt + promptPrefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (promptPrefix && this.isOmni !== true) {
|
const noSystemModelRegex = /\b(o1-preview|o1-mini)\b/i.test(this.modelOptions.model);
|
||||||
|
|
||||||
|
if (promptPrefix && !noSystemModelRegex) {
|
||||||
promptPrefix = `Instructions:\n${promptPrefix.trim()}`;
|
promptPrefix = `Instructions:\n${promptPrefix.trim()}`;
|
||||||
instructions = {
|
instructions = {
|
||||||
role: 'system',
|
role: 'system',
|
||||||
@@ -499,7 +505,7 @@ class OpenAIClient extends BaseClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** EXPERIMENTAL */
|
/** EXPERIMENTAL */
|
||||||
if (promptPrefix && this.isOmni === true) {
|
if (promptPrefix && noSystemModelRegex) {
|
||||||
const lastUserMessageIndex = payload.findLastIndex((message) => message.role === 'user');
|
const lastUserMessageIndex = payload.findLastIndex((message) => message.role === 'user');
|
||||||
if (lastUserMessageIndex !== -1) {
|
if (lastUserMessageIndex !== -1) {
|
||||||
if (Array.isArray(payload[lastUserMessageIndex].content)) {
|
if (Array.isArray(payload[lastUserMessageIndex].content)) {
|
||||||
@@ -608,7 +614,7 @@ class OpenAIClient extends BaseClient {
|
|||||||
return result.trim();
|
return result.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('[OpenAIClient] sendCompletion: result', result);
|
logger.debug('[OpenAIClient] sendCompletion: result', { ...result });
|
||||||
|
|
||||||
if (this.isChatCompletion) {
|
if (this.isChatCompletion) {
|
||||||
reply = result.choices[0].message.content;
|
reply = result.choices[0].message.content;
|
||||||
@@ -817,7 +823,7 @@ ${convo}
|
|||||||
|
|
||||||
const completionTokens = this.getTokenCount(title);
|
const completionTokens = this.getTokenCount(title);
|
||||||
|
|
||||||
this.recordTokenUsage({ promptTokens, completionTokens, context: 'title' });
|
await this.recordTokenUsage({ promptTokens, completionTokens, context: 'title' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'[OpenAIClient] There was an issue generating the title with the completion method',
|
'[OpenAIClient] There was an issue generating the title with the completion method',
|
||||||
@@ -1121,15 +1127,8 @@ ${convo}
|
|||||||
if (msg.text != null && msg.text && msg.text.startsWith(':::thinking')) {
|
if (msg.text != null && msg.text && msg.text.startsWith(':::thinking')) {
|
||||||
msg.text = msg.text.replace(/:::thinking.*?:::/gs, '').trim();
|
msg.text = msg.text.replace(/:::thinking.*?:::/gs, '').trim();
|
||||||
} else if (msg.content != null) {
|
} else if (msg.content != null) {
|
||||||
/** @type {import('@librechat/agents').MessageContentComplex} */
|
msg.text = parseTextParts(msg.content, true);
|
||||||
const newContent = [];
|
delete msg.content;
|
||||||
for (let part of msg.content) {
|
|
||||||
if (part.think != null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
newContent.push(part);
|
|
||||||
}
|
|
||||||
msg.content = newContent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return msg;
|
return msg;
|
||||||
@@ -1230,9 +1229,9 @@ ${convo}
|
|||||||
|
|
||||||
opts.baseURL = this.langchainProxy
|
opts.baseURL = this.langchainProxy
|
||||||
? constructAzureURL({
|
? constructAzureURL({
|
||||||
baseURL: this.langchainProxy,
|
baseURL: this.langchainProxy,
|
||||||
azureOptions: this.azure,
|
azureOptions: this.azure,
|
||||||
})
|
})
|
||||||
: this.azureEndpoint.split(/(?<!\/)\/(chat|completion)\//)[0];
|
: this.azureEndpoint.split(/(?<!\/)\/(chat|completion)\//)[0];
|
||||||
|
|
||||||
opts.defaultQuery = { 'api-version': this.azure.azureOpenAIApiVersion };
|
opts.defaultQuery = { 'api-version': this.azure.azureOpenAIApiVersion };
|
||||||
@@ -1243,6 +1242,9 @@ ${convo}
|
|||||||
modelOptions.max_completion_tokens = modelOptions.max_tokens;
|
modelOptions.max_completion_tokens = modelOptions.max_tokens;
|
||||||
delete modelOptions.max_tokens;
|
delete modelOptions.max_tokens;
|
||||||
}
|
}
|
||||||
|
if (this.isOmni === true && modelOptions.temperature != null) {
|
||||||
|
delete modelOptions.temperature;
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env.OPENAI_ORGANIZATION) {
|
if (process.env.OPENAI_ORGANIZATION) {
|
||||||
opts.organization = process.env.OPENAI_ORGANIZATION;
|
opts.organization = process.env.OPENAI_ORGANIZATION;
|
||||||
@@ -1251,7 +1253,10 @@ ${convo}
|
|||||||
let chatCompletion;
|
let chatCompletion;
|
||||||
/** @type {OpenAI} */
|
/** @type {OpenAI} */
|
||||||
const openai = new OpenAI({
|
const openai = new OpenAI({
|
||||||
fetch: this.fetch,
|
fetch: createFetch({
|
||||||
|
directEndpoint: this.options.directEndpoint,
|
||||||
|
reverseProxyUrl: this.options.reverseProxyUrl,
|
||||||
|
}),
|
||||||
apiKey: this.apiKey,
|
apiKey: this.apiKey,
|
||||||
...opts,
|
...opts,
|
||||||
});
|
});
|
||||||
@@ -1280,13 +1285,22 @@ ${convo}
|
|||||||
modelOptions.messages[0].role = 'user';
|
modelOptions.messages[0].role = 'user';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(this.options.endpoint === EModelEndpoint.openAI ||
|
||||||
|
this.options.endpoint === EModelEndpoint.azureOpenAI) &&
|
||||||
|
modelOptions.stream === true
|
||||||
|
) {
|
||||||
|
modelOptions.stream_options = { include_usage: true };
|
||||||
|
}
|
||||||
|
|
||||||
if (this.options.addParams && typeof this.options.addParams === 'object') {
|
if (this.options.addParams && typeof this.options.addParams === 'object') {
|
||||||
|
const addParams = { ...this.options.addParams };
|
||||||
modelOptions = {
|
modelOptions = {
|
||||||
...modelOptions,
|
...modelOptions,
|
||||||
...this.options.addParams,
|
...addParams,
|
||||||
};
|
};
|
||||||
logger.debug('[OpenAIClient] chatCompletion: added params', {
|
logger.debug('[OpenAIClient] chatCompletion: added params', {
|
||||||
addParams: this.options.addParams,
|
addParams: addParams,
|
||||||
modelOptions,
|
modelOptions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1315,11 +1329,12 @@ ${convo}
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.options.dropParams && Array.isArray(this.options.dropParams)) {
|
if (this.options.dropParams && Array.isArray(this.options.dropParams)) {
|
||||||
this.options.dropParams.forEach((param) => {
|
const dropParams = [...this.options.dropParams];
|
||||||
|
dropParams.forEach((param) => {
|
||||||
delete modelOptions[param];
|
delete modelOptions[param];
|
||||||
});
|
});
|
||||||
logger.debug('[OpenAIClient] chatCompletion: dropped params', {
|
logger.debug('[OpenAIClient] chatCompletion: dropped params', {
|
||||||
dropParams: this.options.dropParams,
|
dropParams: dropParams,
|
||||||
modelOptions,
|
modelOptions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1361,15 +1376,12 @@ ${convo}
|
|||||||
delete modelOptions.reasoning_effort;
|
delete modelOptions.reasoning_effort;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlers = createStreamEventHandlers(this.options.res);
|
||||||
this.streamHandler = new SplitStreamHandler({
|
this.streamHandler = new SplitStreamHandler({
|
||||||
reasoningKey,
|
reasoningKey,
|
||||||
accumulate: true,
|
accumulate: true,
|
||||||
runId: this.responseMessageId,
|
runId: this.responseMessageId,
|
||||||
handlers: {
|
handlers,
|
||||||
[GraphEvents.ON_RUN_STEP]: (event) => sendEvent(this.options.res, event),
|
|
||||||
[GraphEvents.ON_MESSAGE_DELTA]: (event) => sendEvent(this.options.res, event),
|
|
||||||
[GraphEvents.ON_REASONING_DELTA]: (event) => sendEvent(this.options.res, event),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
intermediateReply = this.streamHandler.tokens;
|
intermediateReply = this.streamHandler.tokens;
|
||||||
@@ -1383,12 +1395,6 @@ ${convo}
|
|||||||
...modelOptions,
|
...modelOptions,
|
||||||
stream: true,
|
stream: true,
|
||||||
};
|
};
|
||||||
if (
|
|
||||||
this.options.endpoint === EModelEndpoint.openAI ||
|
|
||||||
this.options.endpoint === EModelEndpoint.azureOpenAI
|
|
||||||
) {
|
|
||||||
params.stream_options = { include_usage: true };
|
|
||||||
}
|
|
||||||
const stream = await openai.beta.chat.completions
|
const stream = await openai.beta.chat.completions
|
||||||
.stream(params)
|
.stream(params)
|
||||||
.on('abort', () => {
|
.on('abort', () => {
|
||||||
@@ -1473,6 +1479,11 @@ ${convo}
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (openai.abortHandler && abortController.signal) {
|
||||||
|
abortController.signal.removeEventListener('abort', openai.abortHandler);
|
||||||
|
openai.abortHandler = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
if (!chatCompletion && UnexpectedRoleError) {
|
if (!chatCompletion && UnexpectedRoleError) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'OpenAI error: Invalid final message: OpenAI expects final message to include role=assistant',
|
'OpenAI error: Invalid final message: OpenAI expects final message to include role=assistant',
|
||||||
|
|||||||
@@ -252,12 +252,14 @@ class PluginsClient extends OpenAIClient {
|
|||||||
await this.recordTokenUsage(responseMessage);
|
await this.recordTokenUsage(responseMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.responsePromise = this.saveMessageToDatabase(responseMessage, saveOptions, user);
|
const databasePromise = this.saveMessageToDatabase(responseMessage, saveOptions, user);
|
||||||
delete responseMessage.tokenCount;
|
delete responseMessage.tokenCount;
|
||||||
return { ...responseMessage, ...result };
|
return { ...responseMessage, ...result, databasePromise };
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(message, opts = {}) {
|
async sendMessage(message, opts = {}) {
|
||||||
|
/** @type {Promise<TMessage>} */
|
||||||
|
let userMessagePromise;
|
||||||
/** @type {{ filteredTools: string[], includedTools: string[] }} */
|
/** @type {{ filteredTools: string[], includedTools: string[] }} */
|
||||||
const { filteredTools = [], includedTools = [] } = this.options.req.app.locals;
|
const { filteredTools = [], includedTools = [] } = this.options.req.app.locals;
|
||||||
|
|
||||||
@@ -327,10 +329,10 @@ class PluginsClient extends OpenAIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this.skipSaveUserMessage) {
|
if (!this.skipSaveUserMessage) {
|
||||||
this.userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user);
|
userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user);
|
||||||
if (typeof opts?.getReqData === 'function') {
|
if (typeof opts?.getReqData === 'function') {
|
||||||
opts.getReqData({
|
opts.getReqData({
|
||||||
userMessagePromise: this.userMessagePromise,
|
userMessagePromise,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
71
api/app/clients/generators.js
Normal file
71
api/app/clients/generators.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
const fetch = require('node-fetch');
|
||||||
|
const { GraphEvents } = require('@librechat/agents');
|
||||||
|
const { logger, sendEvent } = require('~/config');
|
||||||
|
const { sleep } = require('~/server/utils');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a function to make HTTP request and logs the process.
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {boolean} [params.directEndpoint] - Whether to use a direct endpoint.
|
||||||
|
* @param {string} [params.reverseProxyUrl] - The reverse proxy URL to use for the request.
|
||||||
|
* @returns {Promise<Response>} - A promise that resolves to the response of the fetch request.
|
||||||
|
*/
|
||||||
|
function createFetch({ directEndpoint = false, reverseProxyUrl = '' }) {
|
||||||
|
/**
|
||||||
|
* Makes an HTTP request and logs the process.
|
||||||
|
* @param {RequestInfo} url - The URL to make the request to. Can be a string or a Request object.
|
||||||
|
* @param {RequestInit} [init] - Optional init options for the request.
|
||||||
|
* @returns {Promise<Response>} - A promise that resolves to the response of the fetch request.
|
||||||
|
*/
|
||||||
|
return async (_url, init) => {
|
||||||
|
let url = _url;
|
||||||
|
if (directEndpoint) {
|
||||||
|
url = reverseProxyUrl;
|
||||||
|
}
|
||||||
|
logger.debug(`Making request to ${url}`);
|
||||||
|
if (typeof Bun !== 'undefined') {
|
||||||
|
return await fetch(url, init);
|
||||||
|
}
|
||||||
|
return await fetch(url, init);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add this at the module level outside the class
|
||||||
|
/**
|
||||||
|
* Creates event handlers for stream events that don't capture client references
|
||||||
|
* @param {Object} res - The response object to send events to
|
||||||
|
* @returns {Object} Object containing handler functions
|
||||||
|
*/
|
||||||
|
function createStreamEventHandlers(res) {
|
||||||
|
return {
|
||||||
|
[GraphEvents.ON_RUN_STEP]: (event) => {
|
||||||
|
if (res) {
|
||||||
|
sendEvent(res, event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[GraphEvents.ON_MESSAGE_DELTA]: (event) => {
|
||||||
|
if (res) {
|
||||||
|
sendEvent(res, event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[GraphEvents.ON_REASONING_DELTA]: (event) => {
|
||||||
|
if (res) {
|
||||||
|
sendEvent(res, event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHandleLLMNewToken(streamRate) {
|
||||||
|
return async () => {
|
||||||
|
if (streamRate) {
|
||||||
|
await sleep(streamRate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createFetch,
|
||||||
|
createHandleLLMNewToken,
|
||||||
|
createStreamEventHandlers,
|
||||||
|
};
|
||||||
@@ -34,6 +34,7 @@ function createLLM({
|
|||||||
let credentials = { openAIApiKey };
|
let credentials = { openAIApiKey };
|
||||||
let configuration = {
|
let configuration = {
|
||||||
apiKey: openAIApiKey,
|
apiKey: openAIApiKey,
|
||||||
|
...(configOptions.basePath && { baseURL: configOptions.basePath }),
|
||||||
};
|
};
|
||||||
|
|
||||||
/** @type {AzureOptions} */
|
/** @type {AzureOptions} */
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ jest.mock('~/models', () => ({
|
|||||||
|
|
||||||
const { getConvo, saveConvo } = require('~/models');
|
const { getConvo, saveConvo } = require('~/models');
|
||||||
|
|
||||||
jest.mock('@langchain/openai', () => {
|
jest.mock('@librechat/agents', () => {
|
||||||
return {
|
return {
|
||||||
ChatOpenAI: jest.fn().mockImplementation(() => {
|
ChatOpenAI: jest.fn().mockImplementation(() => {
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
jest.mock('~/cache/getLogStores');
|
jest.mock('~/cache/getLogStores');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
const OpenAI = require('openai');
|
|
||||||
const getLogStores = require('~/cache/getLogStores');
|
|
||||||
const { fetchEventSource } = require('@waylaidwanderer/fetch-event-source');
|
const { fetchEventSource } = require('@waylaidwanderer/fetch-event-source');
|
||||||
const { genAzureChatCompletion } = require('~/utils/azureUtils');
|
const getLogStores = require('~/cache/getLogStores');
|
||||||
const OpenAIClient = require('../OpenAIClient');
|
const OpenAIClient = require('../OpenAIClient');
|
||||||
jest.mock('meilisearch');
|
jest.mock('meilisearch');
|
||||||
|
|
||||||
@@ -36,19 +34,21 @@ jest.mock('~/models', () => ({
|
|||||||
updateFileUsage: jest.fn(),
|
updateFileUsage: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('@langchain/openai', () => {
|
// Import the actual module but mock specific parts
|
||||||
return {
|
const agents = jest.requireActual('@librechat/agents');
|
||||||
ChatOpenAI: jest.fn().mockImplementation(() => {
|
const { CustomOpenAIClient } = agents;
|
||||||
return {};
|
|
||||||
}),
|
// Also mock ChatOpenAI to prevent real API calls
|
||||||
};
|
agents.ChatOpenAI = jest.fn().mockImplementation(() => {
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
agents.AzureChatOpenAI = jest.fn().mockImplementation(() => {
|
||||||
|
return {};
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.mock('openai');
|
// Mock only the CustomOpenAIClient constructor
|
||||||
|
jest.spyOn(CustomOpenAIClient, 'constructor').mockImplementation(function (...options) {
|
||||||
jest.spyOn(OpenAI, 'constructor').mockImplementation(function (...options) {
|
return new CustomOpenAIClient(...options);
|
||||||
// We can add additional logic here if needed
|
|
||||||
return new OpenAI(...options);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const finalChatCompletion = jest.fn().mockResolvedValue({
|
const finalChatCompletion = jest.fn().mockResolvedValue({
|
||||||
@@ -120,7 +120,13 @@ const create = jest.fn().mockResolvedValue({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
OpenAI.mockImplementation(() => ({
|
// Mock the implementation of CustomOpenAIClient instances
|
||||||
|
jest.spyOn(CustomOpenAIClient.prototype, 'constructor').mockImplementation(function () {
|
||||||
|
return this;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a mock for the CustomOpenAIClient class
|
||||||
|
const mockCustomOpenAIClient = jest.fn().mockImplementation(() => ({
|
||||||
beta: {
|
beta: {
|
||||||
chat: {
|
chat: {
|
||||||
completions: {
|
completions: {
|
||||||
@@ -135,6 +141,8 @@ OpenAI.mockImplementation(() => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
CustomOpenAIClient.mockImplementation = mockCustomOpenAIClient;
|
||||||
|
|
||||||
describe('OpenAIClient', () => {
|
describe('OpenAIClient', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const mockCache = {
|
const mockCache = {
|
||||||
@@ -559,41 +567,6 @@ describe('OpenAIClient', () => {
|
|||||||
expect(requestBody).toHaveProperty('model');
|
expect(requestBody).toHaveProperty('model');
|
||||||
expect(requestBody.model).toBe(model);
|
expect(requestBody.model).toBe(model);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('[Azure OpenAI] should call chatCompletion and OpenAI.stream with correct args', async () => {
|
|
||||||
// Set a default model
|
|
||||||
process.env.AZURE_OPENAI_DEFAULT_MODEL = 'gpt4-turbo';
|
|
||||||
|
|
||||||
const onProgress = jest.fn().mockImplementation(() => ({}));
|
|
||||||
client.azure = defaultAzureOptions;
|
|
||||||
const chatCompletion = jest.spyOn(client, 'chatCompletion');
|
|
||||||
await client.sendMessage('Hi mom!', {
|
|
||||||
replaceOptions: true,
|
|
||||||
...defaultOptions,
|
|
||||||
modelOptions: { model: 'gpt4-turbo', stream: true },
|
|
||||||
onProgress,
|
|
||||||
azure: defaultAzureOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(chatCompletion).toHaveBeenCalled();
|
|
||||||
expect(chatCompletion.mock.calls.length).toBe(1);
|
|
||||||
|
|
||||||
const chatCompletionArgs = chatCompletion.mock.calls[0][0];
|
|
||||||
const { payload } = chatCompletionArgs;
|
|
||||||
|
|
||||||
expect(payload[0].role).toBe('user');
|
|
||||||
expect(payload[0].content).toBe('Hi mom!');
|
|
||||||
|
|
||||||
// Azure OpenAI does not use the model property, and will error if it's passed
|
|
||||||
// This check ensures the model property is not present
|
|
||||||
const streamArgs = stream.mock.calls[0][0];
|
|
||||||
expect(streamArgs).not.toHaveProperty('model');
|
|
||||||
|
|
||||||
// Check if the baseURL is correct
|
|
||||||
const constructorArgs = OpenAI.mock.calls[0][0];
|
|
||||||
const expectedURL = genAzureChatCompletion(defaultAzureOptions).split('/chat')[0];
|
|
||||||
expect(constructorArgs.baseURL).toBe(expectedURL);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('checkVisionRequest functionality', () => {
|
describe('checkVisionRequest functionality', () => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const StructuredACS = require('./structured/AzureAISearch');
|
|||||||
const StructuredSD = require('./structured/StableDiffusion');
|
const StructuredSD = require('./structured/StableDiffusion');
|
||||||
const GoogleSearchAPI = require('./structured/GoogleSearch');
|
const GoogleSearchAPI = require('./structured/GoogleSearch');
|
||||||
const TraversaalSearch = require('./structured/TraversaalSearch');
|
const TraversaalSearch = require('./structured/TraversaalSearch');
|
||||||
|
const createOpenAIImageTools = require('./structured/OpenAIImageTools');
|
||||||
const TavilySearchResults = require('./structured/TavilySearchResults');
|
const TavilySearchResults = require('./structured/TavilySearchResults');
|
||||||
|
|
||||||
/** @type {Record<string, TPlugin | undefined>} */
|
/** @type {Record<string, TPlugin | undefined>} */
|
||||||
@@ -40,4 +41,5 @@ module.exports = {
|
|||||||
StructuredWolfram,
|
StructuredWolfram,
|
||||||
createYouTubeTools,
|
createYouTubeTools,
|
||||||
TavilySearchResults,
|
TavilySearchResults,
|
||||||
|
createOpenAIImageTools,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,6 +44,20 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "OpenAI Image Tools",
|
||||||
|
"pluginKey": "image_gen_oai",
|
||||||
|
"toolkit": true,
|
||||||
|
"description": "Image Generation and Editing using OpenAI's latest state-of-the-art models",
|
||||||
|
"icon": "/assets/image_gen_oai.png",
|
||||||
|
"authConfig": [
|
||||||
|
{
|
||||||
|
"authField": "IMAGE_GEN_OAI_API_KEY",
|
||||||
|
"label": "OpenAI Image Tools API Key",
|
||||||
|
"description": "Your OpenAI API Key for Image Generation and Editing"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Wolfram",
|
"name": "Wolfram",
|
||||||
"pluginKey": "wolfram",
|
"pluginKey": "wolfram",
|
||||||
|
|||||||
@@ -32,11 +32,15 @@ class AzureAISearch extends Tool {
|
|||||||
fields.AZURE_AI_SEARCH_SERVICE_ENDPOINT,
|
fields.AZURE_AI_SEARCH_SERVICE_ENDPOINT,
|
||||||
'AZURE_AI_SEARCH_SERVICE_ENDPOINT',
|
'AZURE_AI_SEARCH_SERVICE_ENDPOINT',
|
||||||
);
|
);
|
||||||
this.indexName = this._initializeField(
|
// Get the indexes as a comma-separated string
|
||||||
|
this.indexNames = this._initializeField(
|
||||||
fields.AZURE_AI_SEARCH_INDEX_NAME,
|
fields.AZURE_AI_SEARCH_INDEX_NAME,
|
||||||
'AZURE_AI_SEARCH_INDEX_NAME',
|
'AZURE_AI_SEARCH_INDEX_NAME',
|
||||||
);
|
);
|
||||||
this.apiKey = this._initializeField(fields.AZURE_AI_SEARCH_API_KEY, 'AZURE_AI_SEARCH_API_KEY');
|
this.apiKey = this._initializeField(
|
||||||
|
fields.AZURE_AI_SEARCH_API_KEY,
|
||||||
|
'AZURE_AI_SEARCH_API_KEY',
|
||||||
|
);
|
||||||
this.apiVersion = this._initializeField(
|
this.apiVersion = this._initializeField(
|
||||||
fields.AZURE_AI_SEARCH_API_VERSION,
|
fields.AZURE_AI_SEARCH_API_VERSION,
|
||||||
'AZURE_AI_SEARCH_API_VERSION',
|
'AZURE_AI_SEARCH_API_VERSION',
|
||||||
@@ -58,7 +62,7 @@ class AzureAISearch extends Tool {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Check for required fields
|
// Check for required fields
|
||||||
if (!this.override && (!this.serviceEndpoint || !this.indexName || !this.apiKey)) {
|
if (!this.override && (!this.serviceEndpoint || !this.indexNames || !this.apiKey)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Missing AZURE_AI_SEARCH_SERVICE_ENDPOINT, AZURE_AI_SEARCH_INDEX_NAME, or AZURE_AI_SEARCH_API_KEY environment variable.',
|
'Missing AZURE_AI_SEARCH_SERVICE_ENDPOINT, AZURE_AI_SEARCH_INDEX_NAME, or AZURE_AI_SEARCH_API_KEY environment variable.',
|
||||||
);
|
);
|
||||||
@@ -68,12 +72,25 @@ class AzureAISearch extends Tool {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create SearchClient
|
// Split the indexNames by comma to support multiple indexes, trim whitespace,
|
||||||
this.client = new SearchClient(
|
// convert to lowercase, and filter out any empty strings.
|
||||||
this.serviceEndpoint,
|
const indexes = this.indexNames
|
||||||
this.indexName,
|
.split(',')
|
||||||
new AzureKeyCredential(this.apiKey),
|
.map(index => index.trim().toLowerCase())
|
||||||
{ apiVersion: this.apiVersion },
|
.filter(index => index.length > 0);
|
||||||
|
|
||||||
|
if (indexes.length === 0) {
|
||||||
|
throw new Error('No valid index names provided in AZURE_AI_SEARCH_INDEX_NAME.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a client for each index.
|
||||||
|
this.clients = indexes.map(index =>
|
||||||
|
new SearchClient(
|
||||||
|
this.serviceEndpoint,
|
||||||
|
index,
|
||||||
|
new AzureKeyCredential(this.apiKey),
|
||||||
|
{ apiVersion: this.apiVersion },
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,12 +105,21 @@ class AzureAISearch extends Tool {
|
|||||||
if (this.select) {
|
if (this.select) {
|
||||||
searchOption.select = this.select.split(',');
|
searchOption.select = this.select.split(',');
|
||||||
}
|
}
|
||||||
const searchResults = await this.client.search(query, searchOption);
|
|
||||||
const resultDocuments = [];
|
// Query all indexes concurrently
|
||||||
for await (const result of searchResults.results) {
|
const searchPromises = this.clients.map(async (client) => {
|
||||||
resultDocuments.push(result.document);
|
const resultDocuments = [];
|
||||||
}
|
const searchResults = await client.search(query, searchOption);
|
||||||
return JSON.stringify(resultDocuments);
|
for await (const result of searchResults.results) {
|
||||||
|
resultDocuments.push(result.document);
|
||||||
|
}
|
||||||
|
return resultDocuments;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for all search promises to complete and flatten the results
|
||||||
|
const resultsByIndex = await Promise.all(searchPromises);
|
||||||
|
const combinedResults = resultsByIndex.flat();
|
||||||
|
return JSON.stringify(combinedResults);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Azure AI Search request failed', error);
|
logger.error('Azure AI Search request failed', error);
|
||||||
return 'There was an error with Azure AI Search.';
|
return 'There was an error with Azure AI Search.';
|
||||||
@@ -101,4 +127,4 @@ class AzureAISearch extends Tool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = AzureAISearch;
|
module.exports = AzureAISearch;
|
||||||
518
api/app/clients/tools/structured/OpenAIImageTools.js
Normal file
518
api/app/clients/tools/structured/OpenAIImageTools.js
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
const { z } = require('zod');
|
||||||
|
const axios = require('axios');
|
||||||
|
const { v4 } = require('uuid');
|
||||||
|
const OpenAI = require('openai');
|
||||||
|
const FormData = require('form-data');
|
||||||
|
const { tool } = require('@langchain/core/tools');
|
||||||
|
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||||
|
const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
|
||||||
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||||
|
const { logAxiosError, extractBaseURL } = require('~/utils');
|
||||||
|
const { getFiles } = require('~/models/File');
|
||||||
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
|
/** Default descriptions for image generation tool */
|
||||||
|
const DEFAULT_IMAGE_GEN_DESCRIPTION = `
|
||||||
|
Generates high-quality, original images based solely on text, not using any uploaded reference images.
|
||||||
|
|
||||||
|
When to use \`image_gen_oai\`:
|
||||||
|
- To create entirely new images from detailed text descriptions that do NOT reference any image files.
|
||||||
|
|
||||||
|
When NOT to use \`image_gen_oai\`:
|
||||||
|
- If the user has uploaded any images and requests modifications, enhancements, or remixing based on those uploads → use \`image_edit_oai\` instead.
|
||||||
|
|
||||||
|
Generated image IDs will be returned in the response, so you can refer to them in future requests made to \`image_edit_oai\`.
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
/** Default description for image editing tool */
|
||||||
|
const DEFAULT_IMAGE_EDIT_DESCRIPTION =
|
||||||
|
`Generates high-quality, original images based on text and one or more uploaded/referenced images.
|
||||||
|
|
||||||
|
When to use \`image_edit_oai\`:
|
||||||
|
- The user wants to modify, extend, or remix one **or more** uploaded images, either:
|
||||||
|
- Previously generated, or in the current request (both to be included in the \`image_ids\` array).
|
||||||
|
- Always when the user refers to uploaded images for editing, enhancement, remixing, style transfer, or combining elements.
|
||||||
|
- Any current or existing images are to be used as visual guides.
|
||||||
|
- If there are any files in the current request, they are more likely than not expected as references for image edit requests.
|
||||||
|
|
||||||
|
When NOT to use \`image_edit_oai\`:
|
||||||
|
- Brand-new generations that do not rely on an existing image → use \`image_gen_oai\` instead.
|
||||||
|
|
||||||
|
Both generated and referenced image IDs will be returned in the response, so you can refer to them in future requests made to \`image_edit_oai\`.
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
/** Default prompt descriptions */
|
||||||
|
const DEFAULT_IMAGE_GEN_PROMPT_DESCRIPTION = `Describe the image you want in detail.
|
||||||
|
Be highly specific—break your idea into layers:
|
||||||
|
(1) main concept and subject,
|
||||||
|
(2) composition and position,
|
||||||
|
(3) lighting and mood,
|
||||||
|
(4) style, medium, or camera details,
|
||||||
|
(5) important features (age, expression, clothing, etc.),
|
||||||
|
(6) background.
|
||||||
|
Use positive, descriptive language and specify what should be included, not what to avoid.
|
||||||
|
List number and characteristics of people/objects, and mention style/technical requirements (e.g., "DSLR photo, 85mm lens, golden hour").
|
||||||
|
Do not reference any uploaded images—use for new image creation from text only.`;
|
||||||
|
|
||||||
|
const DEFAULT_IMAGE_EDIT_PROMPT_DESCRIPTION = `Describe the changes, enhancements, or new ideas to apply to the uploaded image(s).
|
||||||
|
Be highly specific—break your request into layers:
|
||||||
|
(1) main concept or transformation,
|
||||||
|
(2) specific edits/replacements or composition guidance,
|
||||||
|
(3) desired style, mood, or technique,
|
||||||
|
(4) features/items to keep, change, or add (such as objects, people, clothing, lighting, etc.).
|
||||||
|
Use positive, descriptive language and clarify what should be included or changed, not what to avoid.
|
||||||
|
Always base this prompt on the most recently uploaded reference images.`;
|
||||||
|
|
||||||
|
const displayMessage =
|
||||||
|
'The tool displayed an image. All generated images are already plainly visible, so don\'t repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces unwanted characters from the input string
|
||||||
|
* @param {string} inputString - The input string to process
|
||||||
|
* @returns {string} - The processed string
|
||||||
|
*/
|
||||||
|
function replaceUnwantedChars(inputString) {
|
||||||
|
return inputString
|
||||||
|
.replace(/\r\n|\r|\n/g, ' ')
|
||||||
|
.replace(/"/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function returnValue(value) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return [value, {}];
|
||||||
|
} else if (typeof value === 'object') {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return [displayMessage, value];
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getImageGenDescription = () => {
|
||||||
|
return process.env.IMAGE_GEN_OAI_DESCRIPTION || DEFAULT_IMAGE_GEN_DESCRIPTION;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getImageEditDescription = () => {
|
||||||
|
return process.env.IMAGE_EDIT_OAI_DESCRIPTION || DEFAULT_IMAGE_EDIT_DESCRIPTION;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getImageGenPromptDescription = () => {
|
||||||
|
return process.env.IMAGE_GEN_OAI_PROMPT_DESCRIPTION || DEFAULT_IMAGE_GEN_PROMPT_DESCRIPTION;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getImageEditPromptDescription = () => {
|
||||||
|
return process.env.IMAGE_EDIT_OAI_PROMPT_DESCRIPTION || DEFAULT_IMAGE_EDIT_PROMPT_DESCRIPTION;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates OpenAI Image tools (generation and editing)
|
||||||
|
* @param {Object} fields - Configuration fields
|
||||||
|
* @param {ServerRequest} fields.req - Whether the tool is being used in an agent context
|
||||||
|
* @param {boolean} fields.isAgent - Whether the tool is being used in an agent context
|
||||||
|
* @param {string} fields.IMAGE_GEN_OAI_API_KEY - The OpenAI API key
|
||||||
|
* @param {boolean} [fields.override] - Whether to override the API key check, necessary for app initialization
|
||||||
|
* @param {MongoFile[]} [fields.imageFiles] - The images to be used for editing
|
||||||
|
* @returns {Array} - Array of image tools
|
||||||
|
*/
|
||||||
|
function createOpenAIImageTools(fields = {}) {
|
||||||
|
/** @type {boolean} Used to initialize the Tool without necessary variables. */
|
||||||
|
const override = fields.override ?? false;
|
||||||
|
/** @type {boolean} */
|
||||||
|
if (!override && !fields.isAgent) {
|
||||||
|
throw new Error('This tool is only available for agents.');
|
||||||
|
}
|
||||||
|
const { req } = fields;
|
||||||
|
const imageOutputType = req?.app.locals.imageOutputType || EImageOutputType.PNG;
|
||||||
|
const appFileStrategy = req?.app.locals.fileStrategy;
|
||||||
|
|
||||||
|
const getApiKey = () => {
|
||||||
|
const apiKey = process.env.IMAGE_GEN_OAI_API_KEY ?? '';
|
||||||
|
if (!apiKey && !override) {
|
||||||
|
throw new Error('Missing IMAGE_GEN_OAI_API_KEY environment variable.');
|
||||||
|
}
|
||||||
|
return apiKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
let apiKey = fields.IMAGE_GEN_OAI_API_KEY ?? getApiKey();
|
||||||
|
const closureConfig = { apiKey };
|
||||||
|
|
||||||
|
let baseURL = 'https://api.openai.com/v1/';
|
||||||
|
if (!override && process.env.IMAGE_GEN_OAI_BASEURL) {
|
||||||
|
baseURL = extractBaseURL(process.env.IMAGE_GEN_OAI_BASEURL);
|
||||||
|
closureConfig.baseURL = baseURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Azure may not yet support the latest image generation models
|
||||||
|
if (
|
||||||
|
!override &&
|
||||||
|
process.env.IMAGE_GEN_OAI_AZURE_API_VERSION &&
|
||||||
|
process.env.IMAGE_GEN_OAI_BASEURL
|
||||||
|
) {
|
||||||
|
baseURL = process.env.IMAGE_GEN_OAI_BASEURL;
|
||||||
|
closureConfig.baseURL = baseURL;
|
||||||
|
closureConfig.defaultQuery = { 'api-version': process.env.IMAGE_GEN_OAI_AZURE_API_VERSION };
|
||||||
|
closureConfig.defaultHeaders = {
|
||||||
|
'api-key': process.env.IMAGE_GEN_OAI_API_KEY,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
closureConfig.apiKey = process.env.IMAGE_GEN_OAI_API_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageFiles = fields.imageFiles ?? [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Image Generation Tool
|
||||||
|
*/
|
||||||
|
const imageGenTool = tool(
|
||||||
|
async (
|
||||||
|
{
|
||||||
|
prompt,
|
||||||
|
background = 'auto',
|
||||||
|
n = 1,
|
||||||
|
output_compression = 100,
|
||||||
|
quality = 'auto',
|
||||||
|
size = 'auto',
|
||||||
|
},
|
||||||
|
runnableConfig,
|
||||||
|
) => {
|
||||||
|
if (!prompt) {
|
||||||
|
throw new Error('Missing required field: prompt');
|
||||||
|
}
|
||||||
|
const clientConfig = { ...closureConfig };
|
||||||
|
if (process.env.PROXY) {
|
||||||
|
clientConfig.httpAgent = new HttpsProxyAgent(process.env.PROXY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {OpenAI} */
|
||||||
|
const openai = new OpenAI(clientConfig);
|
||||||
|
let output_format = imageOutputType;
|
||||||
|
if (
|
||||||
|
background === 'transparent' &&
|
||||||
|
output_format !== EImageOutputType.PNG &&
|
||||||
|
output_format !== EImageOutputType.WEBP
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
'[ImageGenOAI] Transparent background requires PNG or WebP format, defaulting to PNG',
|
||||||
|
);
|
||||||
|
output_format = EImageOutputType.PNG;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
const derivedSignal = runnableConfig?.signal
|
||||||
|
? AbortSignal.any([runnableConfig.signal])
|
||||||
|
: undefined;
|
||||||
|
resp = await openai.images.generate(
|
||||||
|
{
|
||||||
|
model: 'gpt-image-1',
|
||||||
|
prompt: replaceUnwantedChars(prompt),
|
||||||
|
n: Math.min(Math.max(1, n), 10),
|
||||||
|
background,
|
||||||
|
output_format,
|
||||||
|
output_compression:
|
||||||
|
output_format === EImageOutputType.WEBP || output_format === EImageOutputType.JPEG
|
||||||
|
? output_compression
|
||||||
|
: undefined,
|
||||||
|
quality,
|
||||||
|
size,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
signal: derivedSignal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = '[image_gen_oai] Problem generating the image:';
|
||||||
|
logAxiosError({ error, message });
|
||||||
|
return returnValue(`Something went wrong when trying to generate the image. The OpenAI API may be unavailable:
|
||||||
|
Error Message: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp) {
|
||||||
|
return returnValue(
|
||||||
|
'Something went wrong when trying to generate the image. The OpenAI API may be unavailable',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For gpt-image-1, the response contains base64-encoded images
|
||||||
|
// TODO: handle cost in `resp.usage`
|
||||||
|
const base64Image = resp.data[0].b64_json;
|
||||||
|
|
||||||
|
if (!base64Image) {
|
||||||
|
return returnValue(
|
||||||
|
'No image data returned from OpenAI API. There may be a problem with the API or your configuration.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
{
|
||||||
|
type: ContentTypes.IMAGE_URL,
|
||||||
|
image_url: {
|
||||||
|
url: `data:image/${output_format};base64,${base64Image}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const file_ids = [v4()];
|
||||||
|
const response = [
|
||||||
|
{
|
||||||
|
type: ContentTypes.TEXT,
|
||||||
|
text: displayMessage + `\n\ngenerated_image_id: "${file_ids[0]}"`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return [response, { content, file_ids }];
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'image_gen_oai',
|
||||||
|
description: getImageGenDescription(),
|
||||||
|
schema: z.object({
|
||||||
|
prompt: z.string().max(32000).describe(getImageGenPromptDescription()),
|
||||||
|
background: z
|
||||||
|
.enum(['transparent', 'opaque', 'auto'])
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Sets transparency for the background. Must be one of transparent, opaque or auto (default). When transparent, the output format should be png or webp.',
|
||||||
|
),
|
||||||
|
/*
|
||||||
|
n: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(10)
|
||||||
|
.optional()
|
||||||
|
.describe('The number of images to generate. Must be between 1 and 10.'),
|
||||||
|
output_compression: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.max(100)
|
||||||
|
.optional()
|
||||||
|
.describe('The compression level (0-100%) for webp or jpeg formats. Defaults to 100.'),
|
||||||
|
*/
|
||||||
|
quality: z
|
||||||
|
.enum(['auto', 'high', 'medium', 'low'])
|
||||||
|
.optional()
|
||||||
|
.describe('The quality of the image. One of auto (default), high, medium, or low.'),
|
||||||
|
size: z
|
||||||
|
.enum(['auto', '1024x1024', '1536x1024', '1024x1536'])
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'The size of the generated image. One of 1024x1024, 1536x1024 (landscape), 1024x1536 (portrait), or auto (default).',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
responseFormat: 'content_and_artifact',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Image Editing Tool
|
||||||
|
*/
|
||||||
|
const imageEditTool = tool(
|
||||||
|
async ({ prompt, image_ids, quality = 'auto', size = 'auto' }, runnableConfig) => {
|
||||||
|
if (!prompt) {
|
||||||
|
throw new Error('Missing required field: prompt');
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientConfig = { ...closureConfig };
|
||||||
|
if (process.env.PROXY) {
|
||||||
|
clientConfig.httpAgent = new HttpsProxyAgent(process.env.PROXY);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('model', 'gpt-image-1');
|
||||||
|
formData.append('prompt', replaceUnwantedChars(prompt));
|
||||||
|
// TODO: `mask` support
|
||||||
|
// TODO: more than 1 image support
|
||||||
|
// formData.append('n', n.toString());
|
||||||
|
formData.append('quality', quality);
|
||||||
|
formData.append('size', size);
|
||||||
|
|
||||||
|
/** @type {Record<FileSources, undefined | NodeStreamDownloader<File>>} */
|
||||||
|
const streamMethods = {};
|
||||||
|
|
||||||
|
const requestFilesMap = Object.fromEntries(imageFiles.map((f) => [f.file_id, { ...f }]));
|
||||||
|
|
||||||
|
const orderedFiles = new Array(image_ids.length);
|
||||||
|
const idsToFetch = [];
|
||||||
|
const indexOfMissing = Object.create(null);
|
||||||
|
|
||||||
|
for (let i = 0; i < image_ids.length; i++) {
|
||||||
|
const id = image_ids[i];
|
||||||
|
const file = requestFilesMap[id];
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
orderedFiles[i] = file;
|
||||||
|
} else {
|
||||||
|
idsToFetch.push(id);
|
||||||
|
indexOfMissing[id] = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idsToFetch.length) {
|
||||||
|
const fetchedFiles = await getFiles(
|
||||||
|
{
|
||||||
|
user: req.user.id,
|
||||||
|
file_id: { $in: idsToFetch },
|
||||||
|
height: { $exists: true },
|
||||||
|
width: { $exists: true },
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const file of fetchedFiles) {
|
||||||
|
requestFilesMap[file.file_id] = file;
|
||||||
|
orderedFiles[indexOfMissing[file.file_id]] = file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const imageFile of orderedFiles) {
|
||||||
|
if (!imageFile) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
/** @type {NodeStream<File>} */
|
||||||
|
let stream;
|
||||||
|
/** @type {NodeStreamDownloader<File>} */
|
||||||
|
let getDownloadStream;
|
||||||
|
const source = imageFile.source || appFileStrategy;
|
||||||
|
if (!source) {
|
||||||
|
throw new Error('No source found for image file');
|
||||||
|
}
|
||||||
|
if (streamMethods[source]) {
|
||||||
|
getDownloadStream = streamMethods[source];
|
||||||
|
} else {
|
||||||
|
({ getDownloadStream } = getStrategyFunctions(source));
|
||||||
|
streamMethods[source] = getDownloadStream;
|
||||||
|
}
|
||||||
|
if (!getDownloadStream) {
|
||||||
|
throw new Error(`No download stream method found for source: ${source}`);
|
||||||
|
}
|
||||||
|
stream = await getDownloadStream(req, imageFile.filepath);
|
||||||
|
if (!stream) {
|
||||||
|
throw new Error('Failed to get download stream for image file');
|
||||||
|
}
|
||||||
|
formData.append('image[]', stream, {
|
||||||
|
filename: imageFile.filename,
|
||||||
|
contentType: imageFile.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {import('axios').RawAxiosHeaders} */
|
||||||
|
let headers = {
|
||||||
|
...formData.getHeaders(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.env.IMAGE_GEN_OAI_AZURE_API_VERSION && process.env.IMAGE_GEN_OAI_BASEURL) {
|
||||||
|
headers['api-key'] = apiKey;
|
||||||
|
} else {
|
||||||
|
headers['Authorization'] = `Bearer ${apiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const derivedSignal = runnableConfig?.signal
|
||||||
|
? AbortSignal.any([runnableConfig.signal])
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
/** @type {import('axios').AxiosRequestConfig} */
|
||||||
|
const axiosConfig = {
|
||||||
|
headers,
|
||||||
|
...clientConfig,
|
||||||
|
signal: derivedSignal,
|
||||||
|
baseURL,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.env.IMAGE_GEN_OAI_AZURE_API_VERSION && process.env.IMAGE_GEN_OAI_BASEURL) {
|
||||||
|
axiosConfig.params = {
|
||||||
|
'api-version': process.env.IMAGE_GEN_OAI_AZURE_API_VERSION,
|
||||||
|
...axiosConfig.params,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const response = await axios.post('/images/edits', formData, axiosConfig);
|
||||||
|
|
||||||
|
if (!response.data || !response.data.data || !response.data.data.length) {
|
||||||
|
return returnValue(
|
||||||
|
'No image data returned from OpenAI API. There may be a problem with the API or your configuration.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64Image = response.data.data[0].b64_json;
|
||||||
|
if (!base64Image) {
|
||||||
|
return returnValue(
|
||||||
|
'No image data returned from OpenAI API. There may be a problem with the API or your configuration.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
{
|
||||||
|
type: ContentTypes.IMAGE_URL,
|
||||||
|
image_url: {
|
||||||
|
url: `data:image/${imageOutputType};base64,${base64Image}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const file_ids = [v4()];
|
||||||
|
const textResponse = [
|
||||||
|
{
|
||||||
|
type: ContentTypes.TEXT,
|
||||||
|
text:
|
||||||
|
displayMessage +
|
||||||
|
`\n\ngenerated_image_id: "${file_ids[0]}"\nreferenced_image_ids: ["${image_ids.join('", "')}"]`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return [textResponse, { content, file_ids }];
|
||||||
|
} catch (error) {
|
||||||
|
const message = '[image_edit_oai] Problem editing the image:';
|
||||||
|
logAxiosError({ error, message });
|
||||||
|
return returnValue(`Something went wrong when trying to edit the image. The OpenAI API may be unavailable:
|
||||||
|
Error Message: ${error.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'image_edit_oai',
|
||||||
|
description: getImageEditDescription(),
|
||||||
|
schema: z.object({
|
||||||
|
image_ids: z
|
||||||
|
.array(z.string())
|
||||||
|
.min(1)
|
||||||
|
.describe(
|
||||||
|
`
|
||||||
|
IDs (image ID strings) of previously generated or uploaded images that should guide the edit.
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- If the user's request depends on any prior image(s), copy their image IDs into the \`image_ids\` array (in the same order the user refers to them).
|
||||||
|
- Never invent or hallucinate IDs; only use IDs that are still visible in the conversation context.
|
||||||
|
- If no earlier image is relevant, omit the field entirely.
|
||||||
|
`.trim(),
|
||||||
|
),
|
||||||
|
prompt: z.string().max(32000).describe(getImageEditPromptDescription()),
|
||||||
|
/*
|
||||||
|
n: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(10)
|
||||||
|
.optional()
|
||||||
|
.describe('The number of images to generate. Must be between 1 and 10. Defaults to 1.'),
|
||||||
|
*/
|
||||||
|
quality: z
|
||||||
|
.enum(['auto', 'high', 'medium', 'low'])
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'The quality of the image. One of auto (default), high, medium, or low. High/medium/low only supported for gpt-image-1.',
|
||||||
|
),
|
||||||
|
size: z
|
||||||
|
.enum(['auto', '1024x1024', '1536x1024', '1024x1536', '256x256', '512x512'])
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'The size of the generated images. For gpt-image-1: auto (default), 1024x1024, 1536x1024, 1024x1536. For dall-e-2: 256x256, 512x512, 1024x1024.',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
responseFormat: 'content_and_artifact',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return [imageGenTool, imageEditTool];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createOpenAIImageTools;
|
||||||
@@ -43,9 +43,39 @@ class TavilySearchResults extends Tool {
|
|||||||
.boolean()
|
.boolean()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Whether to include answers in the search results. Default is False.'),
|
.describe('Whether to include answers in the search results. Default is False.'),
|
||||||
// include_raw_content: z.boolean().optional().describe('Whether to include raw content in the search results. Default is False.'),
|
include_raw_content: z
|
||||||
// include_domains: z.array(z.string()).optional().describe('A list of domains to specifically include in the search results.'),
|
.boolean()
|
||||||
// exclude_domains: z.array(z.string()).optional().describe('A list of domains to specifically exclude from the search results.'),
|
.optional()
|
||||||
|
.describe('Whether to include raw content in the search results. Default is False.'),
|
||||||
|
include_domains: z
|
||||||
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
|
.describe('A list of domains to specifically include in the search results.'),
|
||||||
|
exclude_domains: z
|
||||||
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
|
.describe('A list of domains to specifically exclude from the search results.'),
|
||||||
|
topic: z
|
||||||
|
.enum(['general', 'news', 'finance'])
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'The category of the search. Use news ONLY if query SPECIFCALLY mentions the word "news".',
|
||||||
|
),
|
||||||
|
time_range: z
|
||||||
|
.enum(['day', 'week', 'month', 'year', 'd', 'w', 'm', 'y'])
|
||||||
|
.optional()
|
||||||
|
.describe('The time range back from the current date to filter results.'),
|
||||||
|
days: z
|
||||||
|
.number()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe('Number of days back from the current date to include. Only if topic is news.'),
|
||||||
|
include_image_descriptions: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'When include_images is true, also add a descriptive text for each image. Default is false.',
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
const { loadSpecs } = require('./loadSpecs');
|
|
||||||
|
|
||||||
function transformSpec(input) {
|
|
||||||
return {
|
|
||||||
name: input.name_for_human,
|
|
||||||
pluginKey: input.name_for_model,
|
|
||||||
description: input.description_for_human,
|
|
||||||
icon: input?.logo_url ?? 'https://placehold.co/70x70.png',
|
|
||||||
// TODO: add support for authentication
|
|
||||||
isAuthRequired: 'false',
|
|
||||||
authConfig: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addOpenAPISpecs(availableTools) {
|
|
||||||
try {
|
|
||||||
const specs = (await loadSpecs({})).map(transformSpec);
|
|
||||||
if (specs.length > 0) {
|
|
||||||
return [...specs, ...availableTools];
|
|
||||||
}
|
|
||||||
return availableTools;
|
|
||||||
} catch (error) {
|
|
||||||
return availableTools;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
transformSpec,
|
|
||||||
addOpenAPISpecs,
|
|
||||||
};
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
const { addOpenAPISpecs, transformSpec } = require('./addOpenAPISpecs');
|
|
||||||
const { loadSpecs } = require('./loadSpecs');
|
|
||||||
const { createOpenAPIPlugin } = require('../dynamic/OpenAPIPlugin');
|
|
||||||
|
|
||||||
jest.mock('./loadSpecs');
|
|
||||||
jest.mock('../dynamic/OpenAPIPlugin');
|
|
||||||
|
|
||||||
describe('transformSpec', () => {
|
|
||||||
it('should transform input spec to a desired format', () => {
|
|
||||||
const input = {
|
|
||||||
name_for_human: 'Human Name',
|
|
||||||
name_for_model: 'Model Name',
|
|
||||||
description_for_human: 'Human Description',
|
|
||||||
logo_url: 'https://example.com/logo.png',
|
|
||||||
};
|
|
||||||
|
|
||||||
const expectedOutput = {
|
|
||||||
name: 'Human Name',
|
|
||||||
pluginKey: 'Model Name',
|
|
||||||
description: 'Human Description',
|
|
||||||
icon: 'https://example.com/logo.png',
|
|
||||||
isAuthRequired: 'false',
|
|
||||||
authConfig: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(transformSpec(input)).toEqual(expectedOutput);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use default icon if logo_url is not provided', () => {
|
|
||||||
const input = {
|
|
||||||
name_for_human: 'Human Name',
|
|
||||||
name_for_model: 'Model Name',
|
|
||||||
description_for_human: 'Human Description',
|
|
||||||
};
|
|
||||||
|
|
||||||
const expectedOutput = {
|
|
||||||
name: 'Human Name',
|
|
||||||
pluginKey: 'Model Name',
|
|
||||||
description: 'Human Description',
|
|
||||||
icon: 'https://placehold.co/70x70.png',
|
|
||||||
isAuthRequired: 'false',
|
|
||||||
authConfig: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(transformSpec(input)).toEqual(expectedOutput);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('addOpenAPISpecs', () => {
|
|
||||||
it('should add specs to available tools', async () => {
|
|
||||||
const availableTools = ['Tool1', 'Tool2'];
|
|
||||||
const specs = [
|
|
||||||
{
|
|
||||||
name_for_human: 'Human Name',
|
|
||||||
name_for_model: 'Model Name',
|
|
||||||
description_for_human: 'Human Description',
|
|
||||||
logo_url: 'https://example.com/logo.png',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
loadSpecs.mockResolvedValue(specs);
|
|
||||||
createOpenAPIPlugin.mockReturnValue('Plugin');
|
|
||||||
|
|
||||||
const result = await addOpenAPISpecs(availableTools);
|
|
||||||
expect(result).toEqual([...specs.map(transformSpec), ...availableTools]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return available tools if specs loading fails', async () => {
|
|
||||||
const availableTools = ['Tool1', 'Tool2'];
|
|
||||||
|
|
||||||
loadSpecs.mockRejectedValue(new Error('Failed to load specs'));
|
|
||||||
|
|
||||||
const result = await addOpenAPISpecs(availableTools);
|
|
||||||
expect(result).toEqual(availableTools);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
const { Tools, Constants } = require('librechat-data-provider');
|
|
||||||
const { SerpAPI } = require('@langchain/community/tools/serpapi');
|
const { SerpAPI } = require('@langchain/community/tools/serpapi');
|
||||||
const { Calculator } = require('@langchain/community/tools/calculator');
|
const { Calculator } = require('@langchain/community/tools/calculator');
|
||||||
const { createCodeExecutionTool, EnvVar } = require('@librechat/agents');
|
const { createCodeExecutionTool, EnvVar } = require('@librechat/agents');
|
||||||
|
const { Tools, Constants, EToolResources } = require('librechat-data-provider');
|
||||||
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
||||||
const {
|
const {
|
||||||
availableTools,
|
availableTools,
|
||||||
@@ -18,12 +18,12 @@ const {
|
|||||||
StructuredWolfram,
|
StructuredWolfram,
|
||||||
createYouTubeTools,
|
createYouTubeTools,
|
||||||
TavilySearchResults,
|
TavilySearchResults,
|
||||||
|
createOpenAIImageTools,
|
||||||
} = require('../');
|
} = require('../');
|
||||||
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
|
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
|
||||||
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
|
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
|
||||||
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
||||||
const { createMCPTool } = require('~/server/services/MCP');
|
const { createMCPTool } = require('~/server/services/MCP');
|
||||||
const { loadSpecs } = require('./loadSpecs');
|
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
const mcpToolPattern = new RegExp(`^.+${Constants.mcp_delimiter}.+$`);
|
const mcpToolPattern = new RegExp(`^.+${Constants.mcp_delimiter}.+$`);
|
||||||
@@ -123,7 +123,7 @@ const getAuthFields = (toolKey) => {
|
|||||||
*
|
*
|
||||||
* @param {object} object
|
* @param {object} object
|
||||||
* @param {string} object.user
|
* @param {string} object.user
|
||||||
* @param {Agent} [object.agent]
|
* @param {Pick<Agent, 'id' | 'provider' | 'model'>} [object.agent]
|
||||||
* @param {string} [object.model]
|
* @param {string} [object.model]
|
||||||
* @param {EModelEndpoint} [object.endpoint]
|
* @param {EModelEndpoint} [object.endpoint]
|
||||||
* @param {LoadToolOptions} [object.options]
|
* @param {LoadToolOptions} [object.options]
|
||||||
@@ -157,7 +157,7 @@ const loadTools = async ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const customConstructors = {
|
const customConstructors = {
|
||||||
serpapi: async () => {
|
serpapi: async (_toolContextMap) => {
|
||||||
const authFields = getAuthFields('serpapi');
|
const authFields = getAuthFields('serpapi');
|
||||||
let envVar = authFields[0] ?? '';
|
let envVar = authFields[0] ?? '';
|
||||||
let apiKey = process.env[envVar];
|
let apiKey = process.env[envVar];
|
||||||
@@ -170,11 +170,40 @@ const loadTools = async ({
|
|||||||
gl: 'us',
|
gl: 'us',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
youtube: async () => {
|
youtube: async (_toolContextMap) => {
|
||||||
const authFields = getAuthFields('youtube');
|
const authFields = getAuthFields('youtube');
|
||||||
const authValues = await loadAuthValues({ userId: user, authFields });
|
const authValues = await loadAuthValues({ userId: user, authFields });
|
||||||
return createYouTubeTools(authValues);
|
return createYouTubeTools(authValues);
|
||||||
},
|
},
|
||||||
|
image_gen_oai: async (toolContextMap) => {
|
||||||
|
const authFields = getAuthFields('image_gen_oai');
|
||||||
|
const authValues = await loadAuthValues({ userId: user, authFields });
|
||||||
|
const imageFiles = options.tool_resources?.[EToolResources.image_edit]?.files ?? [];
|
||||||
|
let toolContext = '';
|
||||||
|
for (let i = 0; i < imageFiles.length; i++) {
|
||||||
|
const file = imageFiles[i];
|
||||||
|
if (!file) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (i === 0) {
|
||||||
|
toolContext =
|
||||||
|
'Image files provided in this request (their image IDs listed in order of appearance) available for image editing:';
|
||||||
|
}
|
||||||
|
toolContext += `\n\t- ${file.file_id}`;
|
||||||
|
if (i === imageFiles.length - 1) {
|
||||||
|
toolContext += `\n\nInclude any you need in the \`image_ids\` array when calling \`${EToolResources.image_edit}_oai\`. You may also include previously referenced or generated image IDs.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (toolContext) {
|
||||||
|
toolContextMap.image_edit_oai = toolContext;
|
||||||
|
}
|
||||||
|
return createOpenAIImageTools({
|
||||||
|
...authValues,
|
||||||
|
isAgent: !!agent,
|
||||||
|
req: options.req,
|
||||||
|
imageFiles,
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestedTools = {};
|
const requestedTools = {};
|
||||||
@@ -200,8 +229,8 @@ const loadTools = async ({
|
|||||||
serpapi: { location: 'Austin,Texas,United States', hl: 'en', gl: 'us' },
|
serpapi: { location: 'Austin,Texas,United States', hl: 'en', gl: 'us' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @type {Record<string, string>} */
|
||||||
const toolContextMap = {};
|
const toolContextMap = {};
|
||||||
const remainingTools = [];
|
|
||||||
const appTools = options.req?.app?.locals?.availableTools ?? {};
|
const appTools = options.req?.app?.locals?.availableTools ?? {};
|
||||||
|
|
||||||
for (const tool of tools) {
|
for (const tool of tools) {
|
||||||
@@ -246,7 +275,7 @@ const loadTools = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (customConstructors[tool]) {
|
if (customConstructors[tool]) {
|
||||||
requestedTools[tool] = customConstructors[tool];
|
requestedTools[tool] = async () => customConstructors[tool](toolContextMap);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,30 +290,6 @@ const loadTools = async ({
|
|||||||
requestedTools[tool] = toolInstance;
|
requestedTools[tool] = toolInstance;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (functions === true) {
|
|
||||||
remainingTools.push(tool);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let specs = null;
|
|
||||||
if (useSpecs === true && functions === true && remainingTools.length > 0) {
|
|
||||||
specs = await loadSpecs({
|
|
||||||
llm: model,
|
|
||||||
user,
|
|
||||||
message: options.message,
|
|
||||||
memory: options.memory,
|
|
||||||
signal: options.signal,
|
|
||||||
tools: remainingTools,
|
|
||||||
map: true,
|
|
||||||
verbose: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const tool of remainingTools) {
|
|
||||||
if (specs && specs[tool]) {
|
|
||||||
requestedTools[tool] = specs[tool];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (returnMap) {
|
if (returnMap) {
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { z } = require('zod');
|
|
||||||
const { logger } = require('~/config');
|
|
||||||
const { createOpenAPIPlugin } = require('~/app/clients/tools/dynamic/OpenAPIPlugin');
|
|
||||||
|
|
||||||
// The minimum Manifest definition
|
|
||||||
const ManifestDefinition = z.object({
|
|
||||||
schema_version: z.string().optional(),
|
|
||||||
name_for_human: z.string(),
|
|
||||||
name_for_model: z.string(),
|
|
||||||
description_for_human: z.string(),
|
|
||||||
description_for_model: z.string(),
|
|
||||||
auth: z.object({}).optional(),
|
|
||||||
api: z.object({
|
|
||||||
// Spec URL or can be the filename of the OpenAPI spec yaml file,
|
|
||||||
// located in api\app\clients\tools\.well-known\openapi
|
|
||||||
url: z.string(),
|
|
||||||
type: z.string().optional(),
|
|
||||||
is_user_authenticated: z.boolean().nullable().optional(),
|
|
||||||
has_user_authentication: z.boolean().nullable().optional(),
|
|
||||||
}),
|
|
||||||
// use to override any params that the LLM will consistently get wrong
|
|
||||||
params: z.object({}).optional(),
|
|
||||||
logo_url: z.string().optional(),
|
|
||||||
contact_email: z.string().optional(),
|
|
||||||
legal_info_url: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
function validateJson(json) {
|
|
||||||
try {
|
|
||||||
return ManifestDefinition.parse(json);
|
|
||||||
} catch (error) {
|
|
||||||
logger.debug('[validateJson] manifest parsing error', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// omit the LLM to return the well known jsons as objects
|
|
||||||
async function loadSpecs({ llm, user, message, tools = [], map = false, memory, signal }) {
|
|
||||||
const directoryPath = path.join(__dirname, '..', '.well-known');
|
|
||||||
let files = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < tools.length; i++) {
|
|
||||||
const filePath = path.join(directoryPath, tools[i] + '.json');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// If the access Promise is resolved, it means that the file exists
|
|
||||||
// Then we can add it to the files array
|
|
||||||
await fs.promises.access(filePath, fs.constants.F_OK);
|
|
||||||
files.push(tools[i] + '.json');
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`[loadSpecs] File ${tools[i] + '.json'} does not exist`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (files.length === 0) {
|
|
||||||
files = (await fs.promises.readdir(directoryPath)).filter(
|
|
||||||
(file) => path.extname(file) === '.json',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const validJsons = [];
|
|
||||||
const constructorMap = {};
|
|
||||||
|
|
||||||
logger.debug('[validateJson] files', files);
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
if (path.extname(file) === '.json') {
|
|
||||||
const filePath = path.join(directoryPath, file);
|
|
||||||
const fileContent = await fs.promises.readFile(filePath, 'utf8');
|
|
||||||
const json = JSON.parse(fileContent);
|
|
||||||
|
|
||||||
if (!validateJson(json)) {
|
|
||||||
logger.debug('[validateJson] Invalid json', json);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (llm && map) {
|
|
||||||
constructorMap[json.name_for_model] = async () =>
|
|
||||||
await createOpenAPIPlugin({
|
|
||||||
data: json,
|
|
||||||
llm,
|
|
||||||
message,
|
|
||||||
memory,
|
|
||||||
signal,
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (llm) {
|
|
||||||
validJsons.push(createOpenAPIPlugin({ data: json, llm }));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
validJsons.push(json);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (map) {
|
|
||||||
return constructorMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
const plugins = (await Promise.all(validJsons)).filter((plugin) => plugin);
|
|
||||||
|
|
||||||
// logger.debug('[validateJson] plugins', plugins);
|
|
||||||
// logger.debug(plugins[0].name);
|
|
||||||
|
|
||||||
return plugins;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
loadSpecs,
|
|
||||||
validateJson,
|
|
||||||
ManifestDefinition,
|
|
||||||
};
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const { validateJson, loadSpecs, ManifestDefinition } = require('./loadSpecs');
|
|
||||||
const { createOpenAPIPlugin } = require('../dynamic/OpenAPIPlugin');
|
|
||||||
|
|
||||||
jest.mock('../dynamic/OpenAPIPlugin');
|
|
||||||
|
|
||||||
describe('ManifestDefinition', () => {
|
|
||||||
it('should validate correct json', () => {
|
|
||||||
const json = {
|
|
||||||
name_for_human: 'Test',
|
|
||||||
name_for_model: 'Test',
|
|
||||||
description_for_human: 'Test',
|
|
||||||
description_for_model: 'Test',
|
|
||||||
api: {
|
|
||||||
url: 'http://test.com',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(() => ManifestDefinition.parse(json)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not validate incorrect json', () => {
|
|
||||||
const json = {
|
|
||||||
name_for_human: 'Test',
|
|
||||||
name_for_model: 'Test',
|
|
||||||
description_for_human: 'Test',
|
|
||||||
description_for_model: 'Test',
|
|
||||||
api: {
|
|
||||||
url: 123, // incorrect type
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(() => ManifestDefinition.parse(json)).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('validateJson', () => {
|
|
||||||
it('should return parsed json if valid', () => {
|
|
||||||
const json = {
|
|
||||||
name_for_human: 'Test',
|
|
||||||
name_for_model: 'Test',
|
|
||||||
description_for_human: 'Test',
|
|
||||||
description_for_model: 'Test',
|
|
||||||
api: {
|
|
||||||
url: 'http://test.com',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(validateJson(json)).toEqual(json);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false if json is not valid', () => {
|
|
||||||
const json = {
|
|
||||||
name_for_human: 'Test',
|
|
||||||
name_for_model: 'Test',
|
|
||||||
description_for_human: 'Test',
|
|
||||||
description_for_model: 'Test',
|
|
||||||
api: {
|
|
||||||
url: 123, // incorrect type
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(validateJson(json)).toEqual(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('loadSpecs', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.spyOn(fs.promises, 'readdir').mockResolvedValue(['test.json']);
|
|
||||||
jest.spyOn(fs.promises, 'readFile').mockResolvedValue(
|
|
||||||
JSON.stringify({
|
|
||||||
name_for_human: 'Test',
|
|
||||||
name_for_model: 'Test',
|
|
||||||
description_for_human: 'Test',
|
|
||||||
description_for_model: 'Test',
|
|
||||||
api: {
|
|
||||||
url: 'http://test.com',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
createOpenAPIPlugin.mockResolvedValue({});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return plugins', async () => {
|
|
||||||
const plugins = await loadSpecs({ llm: true, verbose: false });
|
|
||||||
|
|
||||||
expect(plugins).toHaveLength(1);
|
|
||||||
expect(createOpenAPIPlugin).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return constructorMap if map is true', async () => {
|
|
||||||
const plugins = await loadSpecs({ llm: {}, map: true, verbose: false });
|
|
||||||
|
|
||||||
expect(plugins).toHaveProperty('Test');
|
|
||||||
expect(createOpenAPIPlugin).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
9
api/cache/clearPendingReq.js
vendored
9
api/cache/clearPendingReq.js
vendored
@@ -1,7 +1,8 @@
|
|||||||
|
const { Time, CacheKeys } = require('librechat-data-provider');
|
||||||
|
const { isEnabled } = require('~/server/utils');
|
||||||
const getLogStores = require('./getLogStores');
|
const getLogStores = require('./getLogStores');
|
||||||
const { isEnabled } = require('../server/utils');
|
|
||||||
const { USE_REDIS, LIMIT_CONCURRENT_MESSAGES } = process.env ?? {};
|
const { USE_REDIS, LIMIT_CONCURRENT_MESSAGES } = process.env ?? {};
|
||||||
const ttl = 1000 * 60 * 1;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear or decrement pending requests from the cache.
|
* Clear or decrement pending requests from the cache.
|
||||||
@@ -28,7 +29,7 @@ const clearPendingReq = async ({ userId, cache: _cache }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const namespace = 'pending_req';
|
const namespace = CacheKeys.PENDING_REQ;
|
||||||
const cache = _cache ?? getLogStores(namespace);
|
const cache = _cache ?? getLogStores(namespace);
|
||||||
|
|
||||||
if (!cache) {
|
if (!cache) {
|
||||||
@@ -39,7 +40,7 @@ const clearPendingReq = async ({ userId, cache: _cache }) => {
|
|||||||
const currentReq = +((await cache.get(key)) ?? 0);
|
const currentReq = +((await cache.get(key)) ?? 0);
|
||||||
|
|
||||||
if (currentReq && currentReq >= 1) {
|
if (currentReq && currentReq >= 1) {
|
||||||
await cache.set(key, currentReq - 1, ttl);
|
await cache.set(key, currentReq - 1, Time.ONE_MINUTE);
|
||||||
} else {
|
} else {
|
||||||
await cache.delete(key);
|
await cache.delete(key);
|
||||||
}
|
}
|
||||||
|
|||||||
6
api/cache/getLogStores.js
vendored
6
api/cache/getLogStores.js
vendored
@@ -1,4 +1,4 @@
|
|||||||
const Keyv = require('keyv');
|
const { Keyv } = require('keyv');
|
||||||
const { CacheKeys, ViolationTypes, Time } = require('librechat-data-provider');
|
const { CacheKeys, ViolationTypes, Time } = require('librechat-data-provider');
|
||||||
const { logFile, violationFile } = require('./keyvFiles');
|
const { logFile, violationFile } = require('./keyvFiles');
|
||||||
const { math, isEnabled } = require('~/server/utils');
|
const { math, isEnabled } = require('~/server/utils');
|
||||||
@@ -19,7 +19,7 @@ const createViolationInstance = (namespace) => {
|
|||||||
// Serve cache from memory so no need to clear it on startup/exit
|
// Serve cache from memory so no need to clear it on startup/exit
|
||||||
const pending_req = isRedisEnabled
|
const pending_req = isRedisEnabled
|
||||||
? new Keyv({ store: keyvRedis })
|
? new Keyv({ store: keyvRedis })
|
||||||
: new Keyv({ namespace: 'pending_req' });
|
: new Keyv({ namespace: CacheKeys.PENDING_REQ });
|
||||||
|
|
||||||
const config = isRedisEnabled
|
const config = isRedisEnabled
|
||||||
? new Keyv({ store: keyvRedis })
|
? new Keyv({ store: keyvRedis })
|
||||||
@@ -64,7 +64,7 @@ const abortKeys = isRedisEnabled
|
|||||||
const namespaces = {
|
const namespaces = {
|
||||||
[CacheKeys.ROLES]: roles,
|
[CacheKeys.ROLES]: roles,
|
||||||
[CacheKeys.CONFIG_STORE]: config,
|
[CacheKeys.CONFIG_STORE]: config,
|
||||||
pending_req,
|
[CacheKeys.PENDING_REQ]: pending_req,
|
||||||
[ViolationTypes.BAN]: new Keyv({ store: keyvMongo, namespace: CacheKeys.BANS, ttl: duration }),
|
[ViolationTypes.BAN]: new Keyv({ store: keyvMongo, namespace: CacheKeys.BANS, ttl: duration }),
|
||||||
[CacheKeys.ENCODED_DOMAINS]: new Keyv({
|
[CacheKeys.ENCODED_DOMAINS]: new Keyv({
|
||||||
store: keyvMongo,
|
store: keyvMongo,
|
||||||
|
|||||||
92
api/cache/ioredisClient.js
vendored
Normal file
92
api/cache/ioredisClient.js
vendored
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const Redis = require('ioredis');
|
||||||
|
const { isEnabled } = require('~/server/utils');
|
||||||
|
const logger = require('~/config/winston');
|
||||||
|
|
||||||
|
const { REDIS_URI, USE_REDIS, USE_REDIS_CLUSTER, REDIS_CA, REDIS_MAX_LISTENERS } = process.env;
|
||||||
|
|
||||||
|
/** @type {import('ioredis').Redis | import('ioredis').Cluster} */
|
||||||
|
let ioredisClient;
|
||||||
|
const redis_max_listeners = Number(REDIS_MAX_LISTENERS) || 40;
|
||||||
|
|
||||||
|
function mapURI(uri) {
|
||||||
|
const regex =
|
||||||
|
/^(?:(?<scheme>\w+):\/\/)?(?:(?<user>[^:@]+)(?::(?<password>[^@]+))?@)?(?<host>[\w.-]+)(?::(?<port>\d{1,5}))?$/;
|
||||||
|
const match = uri.match(regex);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const { scheme, user, password, host, port } = match.groups;
|
||||||
|
|
||||||
|
return {
|
||||||
|
scheme: scheme || 'none',
|
||||||
|
user: user || null,
|
||||||
|
password: password || null,
|
||||||
|
host: host || null,
|
||||||
|
port: port || null,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const parts = uri.split(':');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
return {
|
||||||
|
scheme: 'none',
|
||||||
|
user: null,
|
||||||
|
password: null,
|
||||||
|
host: parts[0],
|
||||||
|
port: parts[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
scheme: 'none',
|
||||||
|
user: null,
|
||||||
|
password: null,
|
||||||
|
host: uri,
|
||||||
|
port: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (REDIS_URI && isEnabled(USE_REDIS)) {
|
||||||
|
let redisOptions = null;
|
||||||
|
|
||||||
|
if (REDIS_CA) {
|
||||||
|
const ca = fs.readFileSync(REDIS_CA);
|
||||||
|
redisOptions = { tls: { ca } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEnabled(USE_REDIS_CLUSTER)) {
|
||||||
|
const hosts = REDIS_URI.split(',').map((item) => {
|
||||||
|
var value = mapURI(item);
|
||||||
|
|
||||||
|
return {
|
||||||
|
host: value.host,
|
||||||
|
port: value.port,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
ioredisClient = new Redis.Cluster(hosts, { redisOptions });
|
||||||
|
} else {
|
||||||
|
ioredisClient = new Redis(REDIS_URI, redisOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
ioredisClient.on('ready', () => {
|
||||||
|
logger.info('IoRedis connection ready');
|
||||||
|
});
|
||||||
|
ioredisClient.on('reconnecting', () => {
|
||||||
|
logger.info('IoRedis connection reconnecting');
|
||||||
|
});
|
||||||
|
ioredisClient.on('end', () => {
|
||||||
|
logger.info('IoRedis connection ended');
|
||||||
|
});
|
||||||
|
ioredisClient.on('close', () => {
|
||||||
|
logger.info('IoRedis connection closed');
|
||||||
|
});
|
||||||
|
ioredisClient.on('error', (err) => logger.error('IoRedis connection error:', err));
|
||||||
|
ioredisClient.setMaxListeners(redis_max_listeners);
|
||||||
|
logger.info(
|
||||||
|
'[Optional] IoRedis initialized for rate limiters. If you have issues, disable Redis or restart the server.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info('[Optional] IoRedis not initialized for rate limiters.');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ioredisClient;
|
||||||
6
api/cache/keyvFiles.js
vendored
6
api/cache/keyvFiles.js
vendored
@@ -1,11 +1,9 @@
|
|||||||
const { KeyvFile } = require('keyv-file');
|
const { KeyvFile } = require('keyv-file');
|
||||||
|
|
||||||
const logFile = new KeyvFile({ filename: './data/logs.json' });
|
const logFile = new KeyvFile({ filename: './data/logs.json' }).setMaxListeners(20);
|
||||||
const pendingReqFile = new KeyvFile({ filename: './data/pendingReqCache.json' });
|
const violationFile = new KeyvFile({ filename: './data/violations.json' }).setMaxListeners(20);
|
||||||
const violationFile = new KeyvFile({ filename: './data/violations.json' });
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
logFile,
|
logFile,
|
||||||
pendingReqFile,
|
|
||||||
violationFile,
|
violationFile,
|
||||||
};
|
};
|
||||||
|
|||||||
269
api/cache/keyvMongo.js
vendored
269
api/cache/keyvMongo.js
vendored
@@ -1,9 +1,272 @@
|
|||||||
const KeyvMongo = require('@keyv/mongo');
|
// api/cache/keyvMongo.js
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const { GridFSBucket } = require('mongodb');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
const { MONGO_URI } = process.env ?? {};
|
const storeMap = new Map();
|
||||||
|
|
||||||
|
class KeyvMongoCustom extends EventEmitter {
|
||||||
|
constructor(url, options = {}) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
url = url || {};
|
||||||
|
if (typeof url === 'string') {
|
||||||
|
url = { url };
|
||||||
|
}
|
||||||
|
if (url.uri) {
|
||||||
|
url = { url: url.uri, ...url };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.opts = {
|
||||||
|
url: 'mongodb://127.0.0.1:27017',
|
||||||
|
collection: 'keyv',
|
||||||
|
...url,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ttlSupport = false;
|
||||||
|
|
||||||
|
// Filter valid options
|
||||||
|
const keyvMongoKeys = new Set([
|
||||||
|
'url',
|
||||||
|
'collection',
|
||||||
|
'namespace',
|
||||||
|
'serialize',
|
||||||
|
'deserialize',
|
||||||
|
'uri',
|
||||||
|
'useGridFS',
|
||||||
|
'dialect',
|
||||||
|
]);
|
||||||
|
this.opts = Object.fromEntries(Object.entries(this.opts).filter(([k]) => keyvMongoKeys.has(k)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to access the store WITHOUT storing a promise on the instance
|
||||||
|
_getClient() {
|
||||||
|
const storeKey = `${this.opts.collection}:${this.opts.useGridFS ? 'gridfs' : 'collection'}`;
|
||||||
|
|
||||||
|
// If we already have the store initialized, return it directly
|
||||||
|
if (storeMap.has(storeKey)) {
|
||||||
|
return Promise.resolve(storeMap.get(storeKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check mongoose connection state
|
||||||
|
if (mongoose.connection.readyState !== 1) {
|
||||||
|
return Promise.reject(
|
||||||
|
new Error('Mongoose connection not ready. Ensure connectDb() is called first.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = mongoose.connection.db;
|
||||||
|
let client;
|
||||||
|
|
||||||
|
if (this.opts.useGridFS) {
|
||||||
|
const bucket = new GridFSBucket(db, {
|
||||||
|
readPreference: this.opts.readPreference,
|
||||||
|
bucketName: this.opts.collection,
|
||||||
|
});
|
||||||
|
const store = db.collection(`${this.opts.collection}.files`);
|
||||||
|
client = { bucket, store, db };
|
||||||
|
} else {
|
||||||
|
const collection = this.opts.collection || 'keyv';
|
||||||
|
const store = db.collection(collection);
|
||||||
|
client = { store, db };
|
||||||
|
}
|
||||||
|
|
||||||
|
storeMap.set(storeKey, client);
|
||||||
|
return Promise.resolve(client);
|
||||||
|
} catch (error) {
|
||||||
|
this.emit('error', error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key) {
|
||||||
|
const client = await this._getClient();
|
||||||
|
|
||||||
|
if (this.opts.useGridFS) {
|
||||||
|
await client.store.updateOne(
|
||||||
|
{
|
||||||
|
filename: key,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
'metadata.lastAccessed': new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const stream = client.bucket.openDownloadStreamByName(key);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const resp = [];
|
||||||
|
stream.on('error', () => {
|
||||||
|
resolve(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('end', () => {
|
||||||
|
const data = Buffer.concat(resp).toString('utf8');
|
||||||
|
resolve(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('data', (chunk) => {
|
||||||
|
resp.push(chunk);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await client.store.findOne({ key: { $eq: key } });
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return document.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMany(keys) {
|
||||||
|
const client = await this._getClient();
|
||||||
|
|
||||||
|
if (this.opts.useGridFS) {
|
||||||
|
const promises = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
promises.push(this.get(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = await Promise.allSettled(promises);
|
||||||
|
const data = [];
|
||||||
|
for (const value of values) {
|
||||||
|
data.push(value.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = await client.store
|
||||||
|
.find({ key: { $in: keys } })
|
||||||
|
.project({ _id: 0, value: 1, key: 1 })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
const results = [...keys];
|
||||||
|
let i = 0;
|
||||||
|
for (const key of keys) {
|
||||||
|
const rowIndex = values.findIndex((row) => row.key === key);
|
||||||
|
results[i] = rowIndex > -1 ? values[rowIndex].value : undefined;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key, value, ttl) {
|
||||||
|
const client = await this._getClient();
|
||||||
|
const expiresAt = typeof ttl === 'number' ? new Date(Date.now() + ttl) : null;
|
||||||
|
|
||||||
|
if (this.opts.useGridFS) {
|
||||||
|
const stream = client.bucket.openUploadStream(key, {
|
||||||
|
metadata: {
|
||||||
|
expiresAt,
|
||||||
|
lastAccessed: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
stream.on('finish', () => {
|
||||||
|
resolve(stream);
|
||||||
|
});
|
||||||
|
stream.end(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.store.updateOne(
|
||||||
|
{ key: { $eq: key } },
|
||||||
|
{ $set: { key, value, expiresAt } },
|
||||||
|
{ upsert: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key) {
|
||||||
|
if (typeof key !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await this._getClient();
|
||||||
|
|
||||||
|
if (this.opts.useGridFS) {
|
||||||
|
try {
|
||||||
|
const bucket = new GridFSBucket(client.db, {
|
||||||
|
bucketName: this.opts.collection,
|
||||||
|
});
|
||||||
|
const files = await bucket.find({ filename: key }).toArray();
|
||||||
|
await client.bucket.delete(files[0]._id);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const object = await client.store.deleteOne({ key: { $eq: key } });
|
||||||
|
return object.deletedCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMany(keys) {
|
||||||
|
const client = await this._getClient();
|
||||||
|
|
||||||
|
if (this.opts.useGridFS) {
|
||||||
|
const bucket = new GridFSBucket(client.db, {
|
||||||
|
bucketName: this.opts.collection,
|
||||||
|
});
|
||||||
|
const files = await bucket.find({ filename: { $in: keys } }).toArray();
|
||||||
|
if (files.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(files.map(async (file) => client.bucket.delete(file._id)));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const object = await client.store.deleteMany({ key: { $in: keys } });
|
||||||
|
return object.deletedCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear() {
|
||||||
|
const client = await this._getClient();
|
||||||
|
|
||||||
|
if (this.opts.useGridFS) {
|
||||||
|
try {
|
||||||
|
await client.bucket.drop();
|
||||||
|
} catch (error) {
|
||||||
|
// Throw error if not "namespace not found" error
|
||||||
|
if (!(error.code === 26)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.store.deleteMany({
|
||||||
|
key: { $regex: this.namespace ? `^${this.namespace}:*` : '' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async has(key) {
|
||||||
|
const client = await this._getClient();
|
||||||
|
const filter = { [this.opts.useGridFS ? 'filename' : 'key']: { $eq: key } };
|
||||||
|
const document = await client.store.countDocuments(filter, { limit: 1 });
|
||||||
|
return document !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-op disconnect
|
||||||
|
async disconnect() {
|
||||||
|
// This is a no-op since we don't want to close the shared mongoose connection
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyvMongo = new KeyvMongoCustom({
|
||||||
|
collection: 'logs',
|
||||||
|
});
|
||||||
|
|
||||||
const keyvMongo = new KeyvMongo(MONGO_URI, { collection: 'logs' });
|
|
||||||
keyvMongo.on('error', (err) => logger.error('KeyvMongo connection error:', err));
|
keyvMongo.on('error', (err) => logger.error('KeyvMongo connection error:', err));
|
||||||
|
|
||||||
module.exports = keyvMongo;
|
module.exports = keyvMongo;
|
||||||
|
|||||||
22
api/cache/keyvRedis.js
vendored
22
api/cache/keyvRedis.js
vendored
@@ -1,6 +1,6 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const ioredis = require('ioredis');
|
const ioredis = require('ioredis');
|
||||||
const KeyvRedis = require('@keyv/redis');
|
const KeyvRedis = require('@keyv/redis').default;
|
||||||
const { isEnabled } = require('~/server/utils');
|
const { isEnabled } = require('~/server/utils');
|
||||||
const logger = require('~/config/winston');
|
const logger = require('~/config/winston');
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ function mapURI(uri) {
|
|||||||
|
|
||||||
if (REDIS_URI && isEnabled(USE_REDIS)) {
|
if (REDIS_URI && isEnabled(USE_REDIS)) {
|
||||||
let redisOptions = null;
|
let redisOptions = null;
|
||||||
|
/** @type {import('@keyv/redis').KeyvRedisOptions} */
|
||||||
let keyvOpts = {
|
let keyvOpts = {
|
||||||
useRedisSets: false,
|
useRedisSets: false,
|
||||||
keyPrefix: redis_prefix,
|
keyPrefix: redis_prefix,
|
||||||
@@ -74,6 +75,25 @@ if (REDIS_URI && isEnabled(USE_REDIS)) {
|
|||||||
} else {
|
} else {
|
||||||
keyvRedis = new KeyvRedis(REDIS_URI, keyvOpts);
|
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.on('error', (err) => logger.error('KeyvRedis connection error:', err));
|
||||||
keyvRedis.setMaxListeners(redis_max_listeners);
|
keyvRedis.setMaxListeners(redis_max_listeners);
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
4
api/cache/redis.js
vendored
4
api/cache/redis.js
vendored
@@ -1,4 +0,0 @@
|
|||||||
const Redis = require('ioredis');
|
|
||||||
const { REDIS_URI } = process.env ?? {};
|
|
||||||
const redis = new Redis.Cluster(REDIS_URI);
|
|
||||||
module.exports = redis;
|
|
||||||
@@ -1,32 +1,35 @@
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { EventSource } = require('eventsource');
|
const { EventSource } = require('eventsource');
|
||||||
const { Time, CacheKeys } = require('librechat-data-provider');
|
const { Time, CacheKeys } = require('librechat-data-provider');
|
||||||
|
const { MCPManager, FlowStateManager } = require('librechat-mcp');
|
||||||
const logger = require('./winston');
|
const logger = require('./winston');
|
||||||
|
|
||||||
global.EventSource = EventSource;
|
global.EventSource = EventSource;
|
||||||
|
|
||||||
|
/** @type {MCPManager} */
|
||||||
let mcpManager = null;
|
let mcpManager = null;
|
||||||
let flowManager = null;
|
let flowManager = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {Promise<MCPManager>}
|
* @param {string} [userId] - Optional user ID, to avoid disconnecting the current user.
|
||||||
|
* @returns {MCPManager}
|
||||||
*/
|
*/
|
||||||
async function getMCPManager() {
|
function getMCPManager(userId) {
|
||||||
if (!mcpManager) {
|
if (!mcpManager) {
|
||||||
const { MCPManager } = await import('librechat-mcp');
|
|
||||||
mcpManager = MCPManager.getInstance(logger);
|
mcpManager = MCPManager.getInstance(logger);
|
||||||
|
} else {
|
||||||
|
mcpManager.checkIdleConnections(userId);
|
||||||
}
|
}
|
||||||
return mcpManager;
|
return mcpManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {(key: string) => Keyv} getLogStores
|
* @param {Keyv} flowsCache
|
||||||
* @returns {Promise<FlowStateManager>}
|
* @returns {FlowStateManager}
|
||||||
*/
|
*/
|
||||||
async function getFlowStateManager(getLogStores) {
|
function getFlowStateManager(flowsCache) {
|
||||||
if (!flowManager) {
|
if (!flowManager) {
|
||||||
const { FlowStateManager } = await import('librechat-mcp');
|
flowManager = new FlowStateManager(flowsCache, {
|
||||||
flowManager = new FlowStateManager(getLogStores(CacheKeys.FLOWS), {
|
|
||||||
ttl: Time.ONE_MINUTE * 3,
|
ttl: Time.ONE_MINUTE * 3,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ module.exports = {
|
|||||||
coverageDirectory: 'coverage',
|
coverageDirectory: 'coverage',
|
||||||
setupFiles: [
|
setupFiles: [
|
||||||
'./test/jestSetup.js',
|
'./test/jestSetup.js',
|
||||||
'./test/__mocks__/KeyvMongo.js',
|
|
||||||
'./test/__mocks__/logger.js',
|
'./test/__mocks__/logger.js',
|
||||||
'./test/__mocks__/fetchEventSource.js',
|
'./test/__mocks__/fetchEventSource.js',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
const mergeSort = require('./mergeSort');
|
|
||||||
const { cleanUpPrimaryKeyValue } = require('./misc');
|
|
||||||
|
|
||||||
function reduceMessages(hits) {
|
|
||||||
const counts = {};
|
|
||||||
|
|
||||||
for (const hit of hits) {
|
|
||||||
if (!counts[hit.conversationId]) {
|
|
||||||
counts[hit.conversationId] = 1;
|
|
||||||
} else {
|
|
||||||
counts[hit.conversationId]++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = [];
|
|
||||||
|
|
||||||
for (const [conversationId, count] of Object.entries(counts)) {
|
|
||||||
result.push({
|
|
||||||
conversationId,
|
|
||||||
count,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return mergeSort(result, (a, b) => b.count - a.count);
|
|
||||||
}
|
|
||||||
|
|
||||||
function reduceHits(hits, titles = []) {
|
|
||||||
const counts = {};
|
|
||||||
const titleMap = {};
|
|
||||||
const convos = [...hits, ...titles];
|
|
||||||
|
|
||||||
for (const convo of convos) {
|
|
||||||
const currentId = cleanUpPrimaryKeyValue(convo.conversationId);
|
|
||||||
if (!counts[currentId]) {
|
|
||||||
counts[currentId] = 1;
|
|
||||||
} else {
|
|
||||||
counts[currentId]++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (convo.title) {
|
|
||||||
// titleMap[currentId] = convo._formatted.title;
|
|
||||||
titleMap[currentId] = convo.title;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = [];
|
|
||||||
|
|
||||||
for (const [conversationId, count] of Object.entries(counts)) {
|
|
||||||
result.push({
|
|
||||||
conversationId,
|
|
||||||
count,
|
|
||||||
title: titleMap[conversationId] ? titleMap[conversationId] : null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return mergeSort(result, (a, b) => b.count - a.count);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { reduceMessages, reduceHits };
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const { SystemRoles } = require('librechat-data-provider');
|
const { agentSchema } = require('@librechat/data-schemas');
|
||||||
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
|
const { SystemRoles, Tools } = require('librechat-data-provider');
|
||||||
|
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
|
||||||
|
require('librechat-data-provider').Constants;
|
||||||
const { CONFIG_STORE, STARTUP_CONFIG } = require('librechat-data-provider').CacheKeys;
|
const { CONFIG_STORE, STARTUP_CONFIG } = require('librechat-data-provider').CacheKeys;
|
||||||
const {
|
const {
|
||||||
getProjectByName,
|
getProjectByName,
|
||||||
@@ -9,7 +11,6 @@ const {
|
|||||||
removeAgentFromAllProjects,
|
removeAgentFromAllProjects,
|
||||||
} = require('./Project');
|
} = require('./Project');
|
||||||
const getLogStores = require('~/cache/getLogStores');
|
const getLogStores = require('~/cache/getLogStores');
|
||||||
const { agentSchema } = require('@librechat/data-schemas');
|
|
||||||
|
|
||||||
const Agent = mongoose.model('agent', agentSchema);
|
const Agent = mongoose.model('agent', agentSchema);
|
||||||
|
|
||||||
@@ -20,7 +21,19 @@ const Agent = mongoose.model('agent', agentSchema);
|
|||||||
* @throws {Error} If the agent creation fails.
|
* @throws {Error} If the agent creation fails.
|
||||||
*/
|
*/
|
||||||
const createAgent = async (agentData) => {
|
const createAgent = async (agentData) => {
|
||||||
return (await Agent.create(agentData)).toObject();
|
const { versions, ...versionData } = agentData;
|
||||||
|
const timestamp = new Date();
|
||||||
|
const initialAgentData = {
|
||||||
|
...agentData,
|
||||||
|
versions: [
|
||||||
|
{
|
||||||
|
...versionData,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return (await Agent.create(initialAgentData)).toObject();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,9 +52,61 @@ const getAgent = async (searchParameter) => await Agent.findOne(searchParameter)
|
|||||||
* @param {Object} params
|
* @param {Object} params
|
||||||
* @param {ServerRequest} params.req
|
* @param {ServerRequest} params.req
|
||||||
* @param {string} params.agent_id
|
* @param {string} params.agent_id
|
||||||
|
* @param {string} params.endpoint
|
||||||
|
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
|
||||||
|
* @returns {Agent|null} The agent document as a plain object, or null if not found.
|
||||||
|
*/
|
||||||
|
const loadEphemeralAgent = ({ req, agent_id, endpoint, model_parameters: _m }) => {
|
||||||
|
const { model, ...model_parameters } = _m;
|
||||||
|
/** @type {Record<string, FunctionTool>} */
|
||||||
|
const availableTools = req.app.locals.availableTools;
|
||||||
|
const mcpServers = new Set(req.body.ephemeralAgent?.mcp);
|
||||||
|
/** @type {string[]} */
|
||||||
|
const tools = [];
|
||||||
|
if (req.body.ephemeralAgent?.execute_code === true) {
|
||||||
|
tools.push(Tools.execute_code);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mcpServers.size > 0) {
|
||||||
|
for (const toolName of Object.keys(availableTools)) {
|
||||||
|
if (!toolName.includes(mcp_delimiter)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const mcpServer = toolName.split(mcp_delimiter)?.[1];
|
||||||
|
if (mcpServer && mcpServers.has(mcpServer)) {
|
||||||
|
tools.push(toolName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const instructions = req.body.promptPrefix;
|
||||||
|
return {
|
||||||
|
id: agent_id,
|
||||||
|
instructions,
|
||||||
|
provider: endpoint,
|
||||||
|
model_parameters,
|
||||||
|
model,
|
||||||
|
tools,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an agent based on the provided ID
|
||||||
|
*
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {ServerRequest} params.req
|
||||||
|
* @param {string} params.agent_id
|
||||||
|
* @param {string} params.endpoint
|
||||||
|
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
|
||||||
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
|
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
|
||||||
*/
|
*/
|
||||||
const loadAgent = async ({ req, agent_id }) => {
|
const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
|
||||||
|
if (!agent_id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (agent_id === EPHEMERAL_AGENT_ID) {
|
||||||
|
return loadEphemeralAgent({ req, agent_id, endpoint, model_parameters });
|
||||||
|
}
|
||||||
const agent = await getAgent({
|
const agent = await getAgent({
|
||||||
id: agent_id,
|
id: agent_id,
|
||||||
});
|
});
|
||||||
@@ -50,6 +115,8 @@ const loadAgent = async ({ req, agent_id }) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
agent.version = agent.versions ? agent.versions.length : 0;
|
||||||
|
|
||||||
if (agent.author.toString() === req.user.id) {
|
if (agent.author.toString() === req.user.id) {
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
@@ -74,18 +141,146 @@ const loadAgent = async ({ req, agent_id }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a version already exists in the versions array, excluding timestamp and author fields
|
||||||
|
* @param {Object} updateData - The update data to compare
|
||||||
|
* @param {Array} versions - The existing versions array
|
||||||
|
* @returns {Object|null} - The matching version if found, null otherwise
|
||||||
|
*/
|
||||||
|
const isDuplicateVersion = (updateData, currentData, versions) => {
|
||||||
|
if (!versions || versions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const excludeFields = [
|
||||||
|
'_id',
|
||||||
|
'id',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
'author',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
'__v',
|
||||||
|
'agent_ids',
|
||||||
|
'versions',
|
||||||
|
];
|
||||||
|
|
||||||
|
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
|
||||||
|
|
||||||
|
if (Object.keys(directUpdates).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wouldBeVersion = { ...currentData, ...directUpdates };
|
||||||
|
const lastVersion = versions[versions.length - 1];
|
||||||
|
|
||||||
|
const allFields = new Set([...Object.keys(wouldBeVersion), ...Object.keys(lastVersion)]);
|
||||||
|
|
||||||
|
const importantFields = Array.from(allFields).filter((field) => !excludeFields.includes(field));
|
||||||
|
|
||||||
|
let isMatch = true;
|
||||||
|
for (const field of importantFields) {
|
||||||
|
if (!wouldBeVersion[field] && !lastVersion[field]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(wouldBeVersion[field]) && Array.isArray(lastVersion[field])) {
|
||||||
|
if (wouldBeVersion[field].length !== lastVersion[field].length) {
|
||||||
|
isMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for projectIds (MongoDB ObjectIds)
|
||||||
|
if (field === 'projectIds') {
|
||||||
|
const wouldBeIds = wouldBeVersion[field].map((id) => id.toString()).sort();
|
||||||
|
const versionIds = lastVersion[field].map((id) => id.toString()).sort();
|
||||||
|
|
||||||
|
if (!wouldBeIds.every((id, i) => id === versionIds[i])) {
|
||||||
|
isMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle arrays of objects like tool_kwargs
|
||||||
|
else if (typeof wouldBeVersion[field][0] === 'object' && wouldBeVersion[field][0] !== null) {
|
||||||
|
const sortedWouldBe = [...wouldBeVersion[field]].map((item) => JSON.stringify(item)).sort();
|
||||||
|
const sortedVersion = [...lastVersion[field]].map((item) => JSON.stringify(item)).sort();
|
||||||
|
|
||||||
|
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
|
||||||
|
isMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const sortedWouldBe = [...wouldBeVersion[field]].sort();
|
||||||
|
const sortedVersion = [...lastVersion[field]].sort();
|
||||||
|
|
||||||
|
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
|
||||||
|
isMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (field === 'model_parameters') {
|
||||||
|
const wouldBeParams = wouldBeVersion[field] || {};
|
||||||
|
const lastVersionParams = lastVersion[field] || {};
|
||||||
|
if (JSON.stringify(wouldBeParams) !== JSON.stringify(lastVersionParams)) {
|
||||||
|
isMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (wouldBeVersion[field] !== lastVersion[field]) {
|
||||||
|
isMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isMatch ? lastVersion : null;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an agent with new data without overwriting existing
|
* Update an agent with new data without overwriting existing
|
||||||
* properties, or create a new agent if it doesn't exist.
|
* properties, or create a new agent if it doesn't exist.
|
||||||
|
* When an agent is updated, a copy of the current state will be saved to the versions array.
|
||||||
*
|
*
|
||||||
* @param {Object} searchParameter - The search parameters to find the agent to update.
|
* @param {Object} searchParameter - The search parameters to find the agent to update.
|
||||||
* @param {string} searchParameter.id - The ID of the agent to update.
|
* @param {string} searchParameter.id - The ID of the agent to update.
|
||||||
* @param {string} [searchParameter.author] - The user ID of the agent's author.
|
* @param {string} [searchParameter.author] - The user ID of the agent's author.
|
||||||
* @param {Object} updateData - An object containing the properties to update.
|
* @param {Object} updateData - An object containing the properties to update.
|
||||||
* @returns {Promise<Agent>} The updated or newly created agent document as a plain object.
|
* @returns {Promise<Agent>} The updated or newly created agent document as a plain object.
|
||||||
|
* @throws {Error} If the update would create a duplicate version
|
||||||
*/
|
*/
|
||||||
const updateAgent = async (searchParameter, updateData) => {
|
const updateAgent = async (searchParameter, updateData) => {
|
||||||
const options = { new: true, upsert: false };
|
const options = { new: true, upsert: false };
|
||||||
|
|
||||||
|
const currentAgent = await Agent.findOne(searchParameter);
|
||||||
|
if (currentAgent) {
|
||||||
|
const { __v, _id, id, versions, ...versionData } = currentAgent.toObject();
|
||||||
|
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
|
||||||
|
|
||||||
|
if (Object.keys(directUpdates).length > 0 && versions && versions.length > 0) {
|
||||||
|
const duplicateVersion = isDuplicateVersion(updateData, versionData, versions);
|
||||||
|
if (duplicateVersion) {
|
||||||
|
const error = new Error(
|
||||||
|
'Duplicate version: This would create a version identical to an existing one',
|
||||||
|
);
|
||||||
|
error.statusCode = 409;
|
||||||
|
error.details = {
|
||||||
|
duplicateVersion,
|
||||||
|
versionIndex: versions.findIndex(
|
||||||
|
(v) => JSON.stringify(duplicateVersion) === JSON.stringify(v),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData.$push = {
|
||||||
|
...($push || {}),
|
||||||
|
versions: {
|
||||||
|
...versionData,
|
||||||
|
...directUpdates,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return Agent.findOneAndUpdate(searchParameter, updateData, options).lean();
|
return Agent.findOneAndUpdate(searchParameter, updateData, options).lean();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,9 +295,11 @@ const updateAgent = async (searchParameter, updateData) => {
|
|||||||
*/
|
*/
|
||||||
const addAgentResourceFile = async ({ agent_id, tool_resource, file_id }) => {
|
const addAgentResourceFile = async ({ agent_id, tool_resource, file_id }) => {
|
||||||
const searchParameter = { id: agent_id };
|
const searchParameter = { id: agent_id };
|
||||||
|
let agent = await getAgent(searchParameter);
|
||||||
|
if (!agent) {
|
||||||
|
throw new Error('Agent not found for adding resource file');
|
||||||
|
}
|
||||||
const fileIdsPath = `tool_resources.${tool_resource}.file_ids`;
|
const fileIdsPath = `tool_resources.${tool_resource}.file_ids`;
|
||||||
|
|
||||||
await Agent.updateOne(
|
await Agent.updateOne(
|
||||||
{
|
{
|
||||||
id: agent_id,
|
id: agent_id,
|
||||||
@@ -115,7 +312,12 @@ const addAgentResourceFile = async ({ agent_id, tool_resource, file_id }) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateData = { $addToSet: { [fileIdsPath]: file_id } };
|
const updateData = {
|
||||||
|
$addToSet: {
|
||||||
|
tools: tool_resource,
|
||||||
|
[fileIdsPath]: file_id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const updatedAgent = await updateAgent(searchParameter, updateData);
|
const updatedAgent = await updateAgent(searchParameter, updateData);
|
||||||
if (updatedAgent) {
|
if (updatedAgent) {
|
||||||
@@ -126,16 +328,17 @@ const addAgentResourceFile = async ({ agent_id, tool_resource, file_id }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes multiple resource files from an agent in a single update.
|
* Removes multiple resource files from an agent using atomic operations.
|
||||||
* @param {object} params
|
* @param {object} params
|
||||||
* @param {string} params.agent_id
|
* @param {string} params.agent_id
|
||||||
* @param {Array<{tool_resource: string, file_id: string}>} params.files
|
* @param {Array<{tool_resource: string, file_id: string}>} params.files
|
||||||
* @returns {Promise<Agent>} The updated agent.
|
* @returns {Promise<Agent>} The updated agent.
|
||||||
|
* @throws {Error} If the agent is not found or update fails.
|
||||||
*/
|
*/
|
||||||
const removeAgentResourceFiles = async ({ agent_id, files }) => {
|
const removeAgentResourceFiles = async ({ agent_id, files }) => {
|
||||||
const searchParameter = { id: agent_id };
|
const searchParameter = { id: agent_id };
|
||||||
|
|
||||||
// associate each tool resource with the respective file ids array
|
// Group files to remove by resource
|
||||||
const filesByResource = files.reduce((acc, { tool_resource, file_id }) => {
|
const filesByResource = files.reduce((acc, { tool_resource, file_id }) => {
|
||||||
if (!acc[tool_resource]) {
|
if (!acc[tool_resource]) {
|
||||||
acc[tool_resource] = [];
|
acc[tool_resource] = [];
|
||||||
@@ -144,42 +347,35 @@ const removeAgentResourceFiles = async ({ agent_id, files }) => {
|
|||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
// build the update aggregation pipeline wich removes file ids from tool resources array
|
// Step 1: Atomically remove file IDs using $pull
|
||||||
// and eventually deletes empty tool resources
|
const pullOps = {};
|
||||||
const updateData = [];
|
const resourcesToCheck = new Set();
|
||||||
Object.entries(filesByResource).forEach(([resource, fileIds]) => {
|
for (const [resource, fileIds] of Object.entries(filesByResource)) {
|
||||||
const toolResourcePath = `tool_resources.${resource}`;
|
const fileIdsPath = `tool_resources.${resource}.file_ids`;
|
||||||
const fileIdsPath = `${toolResourcePath}.file_ids`;
|
pullOps[fileIdsPath] = { $in: fileIds };
|
||||||
|
resourcesToCheck.add(resource);
|
||||||
// file ids removal stage
|
|
||||||
updateData.push({
|
|
||||||
$set: {
|
|
||||||
[fileIdsPath]: {
|
|
||||||
$filter: {
|
|
||||||
input: `$${fileIdsPath}`,
|
|
||||||
cond: { $not: [{ $in: ['$$this', fileIds] }] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// empty tool resource deletion stage
|
|
||||||
updateData.push({
|
|
||||||
$set: {
|
|
||||||
[toolResourcePath]: {
|
|
||||||
$cond: [{ $eq: [`$${fileIdsPath}`, []] }, '$$REMOVE', `$${toolResourcePath}`],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// return the updated agent or throw if no agent matches
|
|
||||||
const updatedAgent = await updateAgent(searchParameter, updateData);
|
|
||||||
if (updatedAgent) {
|
|
||||||
return updatedAgent;
|
|
||||||
} else {
|
|
||||||
throw new Error('Agent not found for removing resource files');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updatePullData = { $pull: pullOps };
|
||||||
|
const agentAfterPull = await Agent.findOneAndUpdate(searchParameter, updatePullData, {
|
||||||
|
new: true,
|
||||||
|
}).lean();
|
||||||
|
|
||||||
|
if (!agentAfterPull) {
|
||||||
|
// Agent might have been deleted concurrently, or never existed.
|
||||||
|
// Check if it existed before trying to throw.
|
||||||
|
const agentExists = await getAgent(searchParameter);
|
||||||
|
if (!agentExists) {
|
||||||
|
throw new Error('Agent not found for removing resource files');
|
||||||
|
}
|
||||||
|
// If it existed but findOneAndUpdate returned null, something else went wrong.
|
||||||
|
throw new Error('Failed to update agent during file removal (pull step)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the agent state directly after the $pull operation.
|
||||||
|
// Skipping the $unset step for now to simplify and test core $pull atomicity.
|
||||||
|
// Empty arrays might remain, but the removal itself should be correct.
|
||||||
|
return agentAfterPull;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -254,7 +450,7 @@ const getListAgents = async (searchParameter) => {
|
|||||||
* This function also updates the corresponding projects to include or exclude the agent ID.
|
* This function also updates the corresponding projects to include or exclude the agent ID.
|
||||||
*
|
*
|
||||||
* @param {Object} params - Parameters for updating the agent's projects.
|
* @param {Object} params - Parameters for updating the agent's projects.
|
||||||
* @param {import('librechat-data-provider').TUser} params.user - Parameters for updating the agent's projects.
|
* @param {MongoUser} params.user - Parameters for updating the agent's projects.
|
||||||
* @param {string} params.agentId - The ID of the agent to update.
|
* @param {string} params.agentId - The ID of the agent to update.
|
||||||
* @param {string[]} [params.projectIds] - Array of project IDs to add to the agent.
|
* @param {string[]} [params.projectIds] - Array of project IDs to add to the agent.
|
||||||
* @param {string[]} [params.removeProjectIds] - Array of project IDs to remove from the agent.
|
* @param {string[]} [params.removeProjectIds] - Array of project IDs to remove from the agent.
|
||||||
@@ -304,6 +500,38 @@ const updateAgentProjects = async ({ user, agentId, projectIds, removeProjectIds
|
|||||||
return await getAgent({ id: agentId });
|
return await getAgent({ id: agentId });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverts an agent to a specific version in its version history.
|
||||||
|
* @param {Object} searchParameter - The search parameters to find the agent to revert.
|
||||||
|
* @param {string} searchParameter.id - The ID of the agent to revert.
|
||||||
|
* @param {string} [searchParameter.author] - The user ID of the agent's author.
|
||||||
|
* @param {number} versionIndex - The index of the version to revert to in the versions array.
|
||||||
|
* @returns {Promise<MongoAgent>} The updated agent document after reverting.
|
||||||
|
* @throws {Error} If the agent is not found or the specified version does not exist.
|
||||||
|
*/
|
||||||
|
const revertAgentVersion = async (searchParameter, versionIndex) => {
|
||||||
|
const agent = await Agent.findOne(searchParameter);
|
||||||
|
if (!agent) {
|
||||||
|
throw new Error('Agent not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agent.versions || !agent.versions[versionIndex]) {
|
||||||
|
throw new Error(`Version ${versionIndex} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revertToVersion = agent.versions[versionIndex];
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
...revertToVersion,
|
||||||
|
};
|
||||||
|
|
||||||
|
delete updateData._id;
|
||||||
|
delete updateData.id;
|
||||||
|
delete updateData.versions;
|
||||||
|
|
||||||
|
return Agent.findOneAndUpdate(searchParameter, updateData, { new: true }).lean();
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
Agent,
|
Agent,
|
||||||
getAgent,
|
getAgent,
|
||||||
@@ -315,4 +543,5 @@ module.exports = {
|
|||||||
updateAgentProjects,
|
updateAgentProjects,
|
||||||
addAgentResourceFile,
|
addAgentResourceFile,
|
||||||
removeAgentResourceFiles,
|
removeAgentResourceFiles,
|
||||||
|
revertAgentVersion,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,25 @@
|
|||||||
|
const originalEnv = {
|
||||||
|
CREDS_KEY: process.env.CREDS_KEY,
|
||||||
|
CREDS_IV: process.env.CREDS_IV,
|
||||||
|
};
|
||||||
|
|
||||||
|
process.env.CREDS_KEY = '0123456789abcdef0123456789abcdef';
|
||||||
|
process.env.CREDS_IV = '0123456789abcdef';
|
||||||
|
|
||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
const { Agent, addAgentResourceFile, removeAgentResourceFiles } = require('./Agent');
|
const {
|
||||||
|
Agent,
|
||||||
|
addAgentResourceFile,
|
||||||
|
removeAgentResourceFiles,
|
||||||
|
createAgent,
|
||||||
|
updateAgent,
|
||||||
|
getAgent,
|
||||||
|
deleteAgent,
|
||||||
|
getListAgents,
|
||||||
|
updateAgentProjects,
|
||||||
|
} = require('./Agent');
|
||||||
|
|
||||||
describe('Agent Resource File Operations', () => {
|
describe('Agent Resource File Operations', () => {
|
||||||
let mongoServer;
|
let mongoServer;
|
||||||
@@ -15,6 +33,8 @@ describe('Agent Resource File Operations', () => {
|
|||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await mongoose.disconnect();
|
await mongoose.disconnect();
|
||||||
await mongoServer.stop();
|
await mongoServer.stop();
|
||||||
|
process.env.CREDS_KEY = originalEnv.CREDS_KEY;
|
||||||
|
process.env.CREDS_IV = originalEnv.CREDS_IV;
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -33,6 +53,50 @@ describe('Agent Resource File Operations', () => {
|
|||||||
return agent;
|
return agent;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
test('should add tool_resource to tools if missing', async () => {
|
||||||
|
const agent = await createBasicAgent();
|
||||||
|
const fileId = uuidv4();
|
||||||
|
const toolResource = 'file_search';
|
||||||
|
|
||||||
|
const updatedAgent = await addAgentResourceFile({
|
||||||
|
agent_id: agent.id,
|
||||||
|
tool_resource: toolResource,
|
||||||
|
file_id: fileId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updatedAgent.tools).toContain(toolResource);
|
||||||
|
expect(Array.isArray(updatedAgent.tools)).toBe(true);
|
||||||
|
// Should not duplicate
|
||||||
|
const count = updatedAgent.tools.filter((t) => t === toolResource).length;
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not duplicate tool_resource in tools if already present', async () => {
|
||||||
|
const agent = await createBasicAgent();
|
||||||
|
const fileId1 = uuidv4();
|
||||||
|
const fileId2 = uuidv4();
|
||||||
|
const toolResource = 'file_search';
|
||||||
|
|
||||||
|
// First add
|
||||||
|
await addAgentResourceFile({
|
||||||
|
agent_id: agent.id,
|
||||||
|
tool_resource: toolResource,
|
||||||
|
file_id: fileId1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second add (should not duplicate)
|
||||||
|
const updatedAgent = await addAgentResourceFile({
|
||||||
|
agent_id: agent.id,
|
||||||
|
tool_resource: toolResource,
|
||||||
|
file_id: fileId2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updatedAgent.tools).toContain(toolResource);
|
||||||
|
expect(Array.isArray(updatedAgent.tools)).toBe(true);
|
||||||
|
const count = updatedAgent.tools.filter((t) => t === toolResource).length;
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('should handle concurrent file additions', async () => {
|
test('should handle concurrent file additions', async () => {
|
||||||
const agent = await createBasicAgent();
|
const agent = await createBasicAgent();
|
||||||
const fileIds = Array.from({ length: 10 }, () => uuidv4());
|
const fileIds = Array.from({ length: 10 }, () => uuidv4());
|
||||||
@@ -157,4 +221,668 @@ describe('Agent Resource File Operations', () => {
|
|||||||
expect(updatedAgent.tool_resources[tool].file_ids).toHaveLength(5);
|
expect(updatedAgent.tool_resources[tool].file_ids).toHaveLength(5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should handle concurrent duplicate additions', async () => {
|
||||||
|
const agent = await createBasicAgent();
|
||||||
|
const fileId = uuidv4();
|
||||||
|
|
||||||
|
// Concurrent additions of the same file
|
||||||
|
const additionPromises = Array.from({ length: 5 }).map(() =>
|
||||||
|
addAgentResourceFile({
|
||||||
|
agent_id: agent.id,
|
||||||
|
tool_resource: 'test_tool',
|
||||||
|
file_id: fileId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(additionPromises);
|
||||||
|
|
||||||
|
const updatedAgent = await Agent.findOne({ id: agent.id });
|
||||||
|
expect(updatedAgent.tool_resources.test_tool.file_ids).toBeDefined();
|
||||||
|
// Should only contain one instance of the fileId
|
||||||
|
expect(updatedAgent.tool_resources.test_tool.file_ids).toHaveLength(1);
|
||||||
|
expect(updatedAgent.tool_resources.test_tool.file_ids[0]).toBe(fileId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle concurrent add and remove of the same file', async () => {
|
||||||
|
const agent = await createBasicAgent();
|
||||||
|
const fileId = uuidv4();
|
||||||
|
|
||||||
|
// First, ensure the file exists (or test might be trivial if remove runs first)
|
||||||
|
await addAgentResourceFile({
|
||||||
|
agent_id: agent.id,
|
||||||
|
tool_resource: 'test_tool',
|
||||||
|
file_id: fileId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Concurrent add (which should be ignored) and remove
|
||||||
|
const operations = [
|
||||||
|
addAgentResourceFile({
|
||||||
|
agent_id: agent.id,
|
||||||
|
tool_resource: 'test_tool',
|
||||||
|
file_id: fileId,
|
||||||
|
}),
|
||||||
|
removeAgentResourceFiles({
|
||||||
|
agent_id: agent.id,
|
||||||
|
files: [{ tool_resource: 'test_tool', file_id: fileId }],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
await Promise.all(operations);
|
||||||
|
|
||||||
|
const updatedAgent = await Agent.findOne({ id: agent.id });
|
||||||
|
// The final state should ideally be that the file is removed,
|
||||||
|
// but the key point is consistency (not duplicated or error state).
|
||||||
|
// Depending on execution order, the file might remain if the add operation's
|
||||||
|
// findOneAndUpdate runs after the remove operation completes.
|
||||||
|
// A more robust check might be that the length is <= 1.
|
||||||
|
// Given the remove uses an update pipeline, it might be more likely to win.
|
||||||
|
// The final state depends on race condition timing (add or remove might "win").
|
||||||
|
// The critical part is that the state is consistent (no duplicates, no errors).
|
||||||
|
// Assert that the fileId is either present exactly once or not present at all.
|
||||||
|
expect(updatedAgent.tool_resources.test_tool.file_ids).toBeDefined();
|
||||||
|
const finalFileIds = updatedAgent.tool_resources.test_tool.file_ids;
|
||||||
|
const count = finalFileIds.filter((id) => id === fileId).length;
|
||||||
|
expect(count).toBeLessThanOrEqual(1); // Should be 0 or 1, never more
|
||||||
|
// Optional: Check overall length is consistent with the count
|
||||||
|
if (count === 0) {
|
||||||
|
expect(finalFileIds).toHaveLength(0);
|
||||||
|
} else {
|
||||||
|
expect(finalFileIds).toHaveLength(1);
|
||||||
|
expect(finalFileIds[0]).toBe(fileId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle concurrent duplicate removals', async () => {
|
||||||
|
const agent = await createBasicAgent();
|
||||||
|
const fileId = uuidv4();
|
||||||
|
|
||||||
|
// Add the file first
|
||||||
|
await addAgentResourceFile({
|
||||||
|
agent_id: agent.id,
|
||||||
|
tool_resource: 'test_tool',
|
||||||
|
file_id: fileId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Concurrent removals of the same file
|
||||||
|
const removalPromises = Array.from({ length: 5 }).map(() =>
|
||||||
|
removeAgentResourceFiles({
|
||||||
|
agent_id: agent.id,
|
||||||
|
files: [{ tool_resource: 'test_tool', file_id: fileId }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(removalPromises);
|
||||||
|
|
||||||
|
const updatedAgent = await Agent.findOne({ id: agent.id });
|
||||||
|
// Check if the array is empty or the tool resource itself is removed
|
||||||
|
const fileIds = updatedAgent.tool_resources?.test_tool?.file_ids ?? [];
|
||||||
|
expect(fileIds).toHaveLength(0);
|
||||||
|
expect(fileIds).not.toContain(fileId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle concurrent removals of different files', async () => {
|
||||||
|
const agent = await createBasicAgent();
|
||||||
|
const fileIds = Array.from({ length: 10 }, () => uuidv4());
|
||||||
|
|
||||||
|
// Add all files first
|
||||||
|
await Promise.all(
|
||||||
|
fileIds.map((fileId) =>
|
||||||
|
addAgentResourceFile({
|
||||||
|
agent_id: agent.id,
|
||||||
|
tool_resource: 'test_tool',
|
||||||
|
file_id: fileId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Concurrently remove all files
|
||||||
|
const removalPromises = fileIds.map((fileId) =>
|
||||||
|
removeAgentResourceFiles({
|
||||||
|
agent_id: agent.id,
|
||||||
|
files: [{ tool_resource: 'test_tool', file_id: fileId }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(removalPromises);
|
||||||
|
|
||||||
|
const updatedAgent = await Agent.findOne({ id: agent.id });
|
||||||
|
// Check if the array is empty or the tool resource itself is removed
|
||||||
|
const finalFileIds = updatedAgent.tool_resources?.test_tool?.file_ids ?? [];
|
||||||
|
expect(finalFileIds).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Agent CRUD Operations', () => {
|
||||||
|
let mongoServer;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mongoServer = await MongoMemoryServer.create();
|
||||||
|
const mongoUri = mongoServer.getUri();
|
||||||
|
await mongoose.connect(mongoUri);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await mongoServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await Agent.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create and get an agent', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
const newAgent = await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
description: 'Test description',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(newAgent).toBeDefined();
|
||||||
|
expect(newAgent.id).toBe(agentId);
|
||||||
|
expect(newAgent.name).toBe('Test Agent');
|
||||||
|
|
||||||
|
const retrievedAgent = await getAgent({ id: agentId });
|
||||||
|
expect(retrievedAgent).toBeDefined();
|
||||||
|
expect(retrievedAgent.id).toBe(agentId);
|
||||||
|
expect(retrievedAgent.name).toBe('Test Agent');
|
||||||
|
expect(retrievedAgent.description).toBe('Test description');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should delete an agent', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Agent To Delete',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentBeforeDelete = await getAgent({ id: agentId });
|
||||||
|
expect(agentBeforeDelete).toBeDefined();
|
||||||
|
|
||||||
|
await deleteAgent({ id: agentId });
|
||||||
|
|
||||||
|
const agentAfterDelete = await getAgent({ id: agentId });
|
||||||
|
expect(agentAfterDelete).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should list agents by author', async () => {
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
const otherAuthorId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
const agentIds = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const id = `agent_${uuidv4()}`;
|
||||||
|
agentIds.push(id);
|
||||||
|
await createAgent({
|
||||||
|
id,
|
||||||
|
name: `Agent ${i}`,
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await createAgent({
|
||||||
|
id: `other_agent_${uuidv4()}`,
|
||||||
|
name: `Other Agent ${i}`,
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: otherAuthorId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getListAgents({ author: authorId.toString() });
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.data).toBeDefined();
|
||||||
|
expect(result.data).toHaveLength(5);
|
||||||
|
expect(result.has_more).toBe(true);
|
||||||
|
|
||||||
|
for (const agent of result.data) {
|
||||||
|
expect(agent.author).toBe(authorId.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update agent projects', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
const projectId1 = new mongoose.Types.ObjectId();
|
||||||
|
const projectId2 = new mongoose.Types.ObjectId();
|
||||||
|
const projectId3 = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Project Test Agent',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
projectIds: [projectId1],
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{ $addToSet: { projectIds: { $each: [projectId2, projectId3] } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await updateAgent({ id: agentId }, { $pull: { projectIds: projectId1 } });
|
||||||
|
|
||||||
|
await updateAgent({ id: agentId }, { projectIds: [projectId2, projectId3] });
|
||||||
|
|
||||||
|
const updatedAgent = await getAgent({ id: agentId });
|
||||||
|
expect(updatedAgent.projectIds).toHaveLength(2);
|
||||||
|
expect(updatedAgent.projectIds.map((id) => id.toString())).toContain(projectId2.toString());
|
||||||
|
expect(updatedAgent.projectIds.map((id) => id.toString())).toContain(projectId3.toString());
|
||||||
|
expect(updatedAgent.projectIds.map((id) => id.toString())).not.toContain(projectId1.toString());
|
||||||
|
|
||||||
|
await updateAgent({ id: agentId }, { projectIds: [] });
|
||||||
|
|
||||||
|
const emptyProjectsAgent = await getAgent({ id: agentId });
|
||||||
|
expect(emptyProjectsAgent.projectIds).toHaveLength(0);
|
||||||
|
|
||||||
|
const nonExistentId = `agent_${uuidv4()}`;
|
||||||
|
await expect(
|
||||||
|
updateAgentProjects({
|
||||||
|
id: nonExistentId,
|
||||||
|
projectIds: [projectId1],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle ephemeral agent loading', async () => {
|
||||||
|
const agentId = 'ephemeral_test';
|
||||||
|
const endpoint = 'openai';
|
||||||
|
|
||||||
|
const originalModule = jest.requireActual('librechat-data-provider');
|
||||||
|
|
||||||
|
const mockDataProvider = {
|
||||||
|
...originalModule,
|
||||||
|
Constants: {
|
||||||
|
...originalModule.Constants,
|
||||||
|
EPHEMERAL_AGENT_ID: 'ephemeral_test',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.doMock('librechat-data-provider', () => mockDataProvider);
|
||||||
|
|
||||||
|
const mockReq = {
|
||||||
|
user: { id: 'user123' },
|
||||||
|
body: {
|
||||||
|
promptPrefix: 'This is a test instruction',
|
||||||
|
ephemeralAgent: {
|
||||||
|
execute_code: true,
|
||||||
|
mcp: ['server1', 'server2'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
locals: {
|
||||||
|
availableTools: {
|
||||||
|
tool__server1: {},
|
||||||
|
tool__server2: {},
|
||||||
|
another_tool: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
req: mockReq,
|
||||||
|
agent_id: agentId,
|
||||||
|
endpoint,
|
||||||
|
model_parameters: {
|
||||||
|
model: 'gpt-4',
|
||||||
|
temperature: 0.7,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(agentId).toBeDefined();
|
||||||
|
expect(endpoint).toBeDefined();
|
||||||
|
|
||||||
|
jest.dontMock('librechat-data-provider');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle loadAgent functionality and errors', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Test Load Agent',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
tools: ['tool1', 'tool2'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const agent = await getAgent({ id: agentId });
|
||||||
|
|
||||||
|
expect(agent).toBeDefined();
|
||||||
|
expect(agent.id).toBe(agentId);
|
||||||
|
expect(agent.name).toBe('Test Load Agent');
|
||||||
|
expect(agent.tools).toEqual(expect.arrayContaining(['tool1', 'tool2']));
|
||||||
|
|
||||||
|
const mockLoadAgent = jest.fn().mockResolvedValue(agent);
|
||||||
|
const loadedAgent = await mockLoadAgent();
|
||||||
|
expect(loadedAgent).toBeDefined();
|
||||||
|
expect(loadedAgent.id).toBe(agentId);
|
||||||
|
|
||||||
|
const nonExistentId = `agent_${uuidv4()}`;
|
||||||
|
const nonExistentAgent = await getAgent({ id: nonExistentId });
|
||||||
|
expect(nonExistentAgent).toBeNull();
|
||||||
|
|
||||||
|
const mockLoadAgentError = jest.fn().mockRejectedValue(new Error('No agent found with ID'));
|
||||||
|
await expect(mockLoadAgentError()).rejects.toThrow('No agent found with ID');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Agent Version History', () => {
|
||||||
|
let mongoServer;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mongoServer = await MongoMemoryServer.create();
|
||||||
|
const mongoUri = mongoServer.getUri();
|
||||||
|
await mongoose.connect(mongoUri);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await mongoServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await Agent.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create an agent with a single entry in versions array', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const agent = await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: new mongoose.Types.ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.versions).toBeDefined();
|
||||||
|
expect(Array.isArray(agent.versions)).toBe(true);
|
||||||
|
expect(agent.versions).toHaveLength(1);
|
||||||
|
expect(agent.versions[0].name).toBe('Test Agent');
|
||||||
|
expect(agent.versions[0].provider).toBe('test');
|
||||||
|
expect(agent.versions[0].model).toBe('test-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accumulate version history across multiple updates', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const author = new mongoose.Types.ObjectId();
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'First Name',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author,
|
||||||
|
description: 'First description',
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateAgent({ id: agentId }, { name: 'Second Name', description: 'Second description' });
|
||||||
|
await updateAgent({ id: agentId }, { name: 'Third Name', model: 'new-model' });
|
||||||
|
const finalAgent = await updateAgent({ id: agentId }, { description: 'Final description' });
|
||||||
|
|
||||||
|
expect(finalAgent.versions).toBeDefined();
|
||||||
|
expect(Array.isArray(finalAgent.versions)).toBe(true);
|
||||||
|
expect(finalAgent.versions).toHaveLength(4);
|
||||||
|
|
||||||
|
expect(finalAgent.versions[0].name).toBe('First Name');
|
||||||
|
expect(finalAgent.versions[0].description).toBe('First description');
|
||||||
|
expect(finalAgent.versions[0].model).toBe('test-model');
|
||||||
|
|
||||||
|
expect(finalAgent.versions[1].name).toBe('Second Name');
|
||||||
|
expect(finalAgent.versions[1].description).toBe('Second description');
|
||||||
|
expect(finalAgent.versions[1].model).toBe('test-model');
|
||||||
|
|
||||||
|
expect(finalAgent.versions[2].name).toBe('Third Name');
|
||||||
|
expect(finalAgent.versions[2].description).toBe('Second description');
|
||||||
|
expect(finalAgent.versions[2].model).toBe('new-model');
|
||||||
|
|
||||||
|
expect(finalAgent.versions[3].name).toBe('Third Name');
|
||||||
|
expect(finalAgent.versions[3].description).toBe('Final description');
|
||||||
|
expect(finalAgent.versions[3].model).toBe('new-model');
|
||||||
|
|
||||||
|
expect(finalAgent.name).toBe('Third Name');
|
||||||
|
expect(finalAgent.description).toBe('Final description');
|
||||||
|
expect(finalAgent.model).toBe('new-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not include metadata fields in version history', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: new mongoose.Types.ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedAgent = await updateAgent({ id: agentId }, { description: 'New description' });
|
||||||
|
|
||||||
|
expect(updatedAgent.versions).toHaveLength(2);
|
||||||
|
expect(updatedAgent.versions[0]._id).toBeUndefined();
|
||||||
|
expect(updatedAgent.versions[0].__v).toBeUndefined();
|
||||||
|
expect(updatedAgent.versions[0].name).toBe('Test Agent');
|
||||||
|
expect(updatedAgent.versions[0].author).toBeDefined();
|
||||||
|
|
||||||
|
expect(updatedAgent.versions[1]._id).toBeUndefined();
|
||||||
|
expect(updatedAgent.versions[1].__v).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not recursively include previous versions', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: new mongoose.Types.ObjectId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateAgent({ id: agentId }, { name: 'Updated Name 1' });
|
||||||
|
await updateAgent({ id: agentId }, { name: 'Updated Name 2' });
|
||||||
|
const finalAgent = await updateAgent({ id: agentId }, { name: 'Updated Name 3' });
|
||||||
|
|
||||||
|
expect(finalAgent.versions).toHaveLength(4);
|
||||||
|
|
||||||
|
finalAgent.versions.forEach((version) => {
|
||||||
|
expect(version.versions).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle MongoDB operators and field updates correctly', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
const projectId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'MongoDB Operator Test',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
tools: ['tool1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{
|
||||||
|
description: 'Updated description',
|
||||||
|
$push: { tools: 'tool2' },
|
||||||
|
$addToSet: { projectIds: projectId },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstUpdate = await getAgent({ id: agentId });
|
||||||
|
expect(firstUpdate.description).toBe('Updated description');
|
||||||
|
expect(firstUpdate.tools).toContain('tool1');
|
||||||
|
expect(firstUpdate.tools).toContain('tool2');
|
||||||
|
expect(firstUpdate.projectIds.map((id) => id.toString())).toContain(projectId.toString());
|
||||||
|
expect(firstUpdate.versions).toHaveLength(2);
|
||||||
|
|
||||||
|
await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{
|
||||||
|
tools: ['tool2', 'tool3'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondUpdate = await getAgent({ id: agentId });
|
||||||
|
expect(secondUpdate.tools).toHaveLength(2);
|
||||||
|
expect(secondUpdate.tools).toContain('tool2');
|
||||||
|
expect(secondUpdate.tools).toContain('tool3');
|
||||||
|
expect(secondUpdate.tools).not.toContain('tool1');
|
||||||
|
expect(secondUpdate.versions).toHaveLength(3);
|
||||||
|
|
||||||
|
await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{
|
||||||
|
$push: { tools: 'tool3' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const thirdUpdate = await getAgent({ id: agentId });
|
||||||
|
const toolCount = thirdUpdate.tools.filter((t) => t === 'tool3').length;
|
||||||
|
expect(toolCount).toBe(2);
|
||||||
|
expect(thirdUpdate.versions).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle parameter objects correctly', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Parameters Test',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
model_parameters: { temperature: 0.7 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedAgent = await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{ model_parameters: { temperature: 0.8 } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updatedAgent.versions).toHaveLength(2);
|
||||||
|
expect(updatedAgent.model_parameters.temperature).toBe(0.8);
|
||||||
|
|
||||||
|
await updateAgent(
|
||||||
|
{ id: agentId },
|
||||||
|
{
|
||||||
|
model_parameters: {
|
||||||
|
temperature: 0.8,
|
||||||
|
max_tokens: 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const complexAgent = await getAgent({ id: agentId });
|
||||||
|
expect(complexAgent.versions).toHaveLength(3);
|
||||||
|
expect(complexAgent.model_parameters.temperature).toBe(0.8);
|
||||||
|
expect(complexAgent.model_parameters.max_tokens).toBe(1000);
|
||||||
|
|
||||||
|
await updateAgent({ id: agentId }, { model_parameters: {} });
|
||||||
|
|
||||||
|
const emptyParamsAgent = await getAgent({ id: agentId });
|
||||||
|
expect(emptyParamsAgent.versions).toHaveLength(4);
|
||||||
|
expect(emptyParamsAgent.model_parameters).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should detect duplicate versions and reject updates', async () => {
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
console.error = jest.fn();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
const projectId1 = new mongoose.Types.ObjectId();
|
||||||
|
const projectId2 = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
name: 'simple field update',
|
||||||
|
initial: {
|
||||||
|
name: 'Test Agent',
|
||||||
|
description: 'Initial description',
|
||||||
|
},
|
||||||
|
update: { name: 'Updated Name' },
|
||||||
|
duplicate: { name: 'Updated Name' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'object field update',
|
||||||
|
initial: {
|
||||||
|
model_parameters: { temperature: 0.7 },
|
||||||
|
},
|
||||||
|
update: { model_parameters: { temperature: 0.8 } },
|
||||||
|
duplicate: { model_parameters: { temperature: 0.8 } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'array field update',
|
||||||
|
initial: {
|
||||||
|
tools: ['tool1', 'tool2'],
|
||||||
|
},
|
||||||
|
update: { tools: ['tool2', 'tool3'] },
|
||||||
|
duplicate: { tools: ['tool2', 'tool3'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'projectIds update',
|
||||||
|
initial: {
|
||||||
|
projectIds: [projectId1],
|
||||||
|
},
|
||||||
|
update: { projectIds: [projectId1, projectId2] },
|
||||||
|
duplicate: { projectIds: [projectId2, projectId1] },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
const testAgentId = `agent_${uuidv4()}`;
|
||||||
|
|
||||||
|
await createAgent({
|
||||||
|
id: testAgentId,
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
...testCase.initial,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateAgent({ id: testAgentId }, testCase.update);
|
||||||
|
|
||||||
|
let error;
|
||||||
|
try {
|
||||||
|
await updateAgent({ id: testAgentId }, testCase.duplicate);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toContain('Duplicate version');
|
||||||
|
expect(error.statusCode).toBe(409);
|
||||||
|
expect(error.details).toBeDefined();
|
||||||
|
expect(error.details.duplicateVersion).toBeDefined();
|
||||||
|
|
||||||
|
const agent = await getAgent({ id: testAgentId });
|
||||||
|
expect(agent.versions).toHaveLength(2);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,11 +88,13 @@ module.exports = {
|
|||||||
*/
|
*/
|
||||||
saveConvo: async (req, { conversationId, newConversationId, ...convo }, metadata) => {
|
saveConvo: async (req, { conversationId, newConversationId, ...convo }, metadata) => {
|
||||||
try {
|
try {
|
||||||
if (metadata && metadata?.context) {
|
if (metadata?.context) {
|
||||||
logger.debug(`[saveConvo] ${metadata.context}`);
|
logger.debug(`[saveConvo] ${metadata.context}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = await getMessages({ conversationId }, '_id');
|
const messages = await getMessages({ conversationId }, '_id');
|
||||||
const update = { ...convo, messages, user: req.user.id };
|
const update = { ...convo, messages, user: req.user.id };
|
||||||
|
|
||||||
if (newConversationId) {
|
if (newConversationId) {
|
||||||
update.conversationId = newConversationId;
|
update.conversationId = newConversationId;
|
||||||
}
|
}
|
||||||
@@ -148,75 +150,102 @@ module.exports = {
|
|||||||
throw new Error('Failed to save conversations in bulk.');
|
throw new Error('Failed to save conversations in bulk.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getConvosByPage: async (user, pageNumber = 1, pageSize = 25, isArchived = false, tags) => {
|
getConvosByCursor: async (
|
||||||
const query = { user };
|
user,
|
||||||
|
{ cursor, limit = 25, isArchived = false, tags, search, order = 'desc' } = {},
|
||||||
|
) => {
|
||||||
|
const filters = [{ user }];
|
||||||
|
|
||||||
if (isArchived) {
|
if (isArchived) {
|
||||||
query.isArchived = true;
|
filters.push({ isArchived: true });
|
||||||
} else {
|
} else {
|
||||||
query.$or = [{ isArchived: false }, { isArchived: { $exists: false } }];
|
filters.push({ $or: [{ isArchived: false }, { isArchived: { $exists: false } }] });
|
||||||
}
|
|
||||||
if (Array.isArray(tags) && tags.length > 0) {
|
|
||||||
query.tags = { $in: tags };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
query.$and = [{ $or: [{ expiredAt: null }, { expiredAt: { $exists: false } }] }];
|
if (Array.isArray(tags) && tags.length > 0) {
|
||||||
|
filters.push({ tags: { $in: tags } });
|
||||||
|
}
|
||||||
|
|
||||||
|
filters.push({ $or: [{ expiredAt: null }, { expiredAt: { $exists: false } }] });
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
try {
|
||||||
|
const meiliResults = await Conversation.meiliSearch(search);
|
||||||
|
const matchingIds = Array.isArray(meiliResults.hits)
|
||||||
|
? meiliResults.hits.map((result) => result.conversationId)
|
||||||
|
: [];
|
||||||
|
if (!matchingIds.length) {
|
||||||
|
return { conversations: [], nextCursor: null };
|
||||||
|
}
|
||||||
|
filters.push({ conversationId: { $in: matchingIds } });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[getConvosByCursor] Error during meiliSearch', error);
|
||||||
|
return { message: 'Error during meiliSearch' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
filters.push({ updatedAt: { $lt: new Date(cursor) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = filters.length === 1 ? filters[0] : { $and: filters };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const totalConvos = (await Conversation.countDocuments(query)) || 1;
|
|
||||||
const totalPages = Math.ceil(totalConvos / pageSize);
|
|
||||||
const convos = await Conversation.find(query)
|
const convos = await Conversation.find(query)
|
||||||
.sort({ updatedAt: -1 })
|
.select(
|
||||||
.skip((pageNumber - 1) * pageSize)
|
'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL',
|
||||||
.limit(pageSize)
|
)
|
||||||
|
.sort({ updatedAt: order === 'asc' ? 1 : -1 })
|
||||||
|
.limit(limit + 1)
|
||||||
.lean();
|
.lean();
|
||||||
return { conversations: convos, pages: totalPages, pageNumber, pageSize };
|
|
||||||
|
let nextCursor = null;
|
||||||
|
if (convos.length > limit) {
|
||||||
|
const lastConvo = convos.pop();
|
||||||
|
nextCursor = lastConvo.updatedAt.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { conversations: convos, nextCursor };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[getConvosByPage] Error getting conversations', error);
|
logger.error('[getConvosByCursor] Error getting conversations', error);
|
||||||
return { message: 'Error getting conversations' };
|
return { message: 'Error getting conversations' };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getConvosQueried: async (user, convoIds, pageNumber = 1, pageSize = 25) => {
|
getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => {
|
||||||
try {
|
try {
|
||||||
if (!convoIds || convoIds.length === 0) {
|
if (!convoIds?.length) {
|
||||||
return { conversations: [], pages: 1, pageNumber, pageSize };
|
return { conversations: [], nextCursor: null, convoMap: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversationIds = convoIds.map((convo) => convo.conversationId);
|
||||||
|
|
||||||
|
const results = await Conversation.find({
|
||||||
|
user,
|
||||||
|
conversationId: { $in: conversationIds },
|
||||||
|
$or: [{ expiredAt: { $exists: false } }, { expiredAt: null }],
|
||||||
|
}).lean();
|
||||||
|
|
||||||
|
results.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
||||||
|
|
||||||
|
let filtered = results;
|
||||||
|
if (cursor && cursor !== 'start') {
|
||||||
|
const cursorDate = new Date(cursor);
|
||||||
|
filtered = results.filter((convo) => new Date(convo.updatedAt) < cursorDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limited = filtered.slice(0, limit + 1);
|
||||||
|
let nextCursor = null;
|
||||||
|
if (limited.length > limit) {
|
||||||
|
const lastConvo = limited.pop();
|
||||||
|
nextCursor = lastConvo.updatedAt.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const cache = {};
|
|
||||||
const convoMap = {};
|
const convoMap = {};
|
||||||
const promises = [];
|
limited.forEach((convo) => {
|
||||||
|
|
||||||
convoIds.forEach((convo) =>
|
|
||||||
promises.push(
|
|
||||||
Conversation.findOne({
|
|
||||||
user,
|
|
||||||
conversationId: convo.conversationId,
|
|
||||||
$or: [{ expiredAt: { $exists: false } }, { expiredAt: null }],
|
|
||||||
}).lean(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const results = (await Promise.all(promises)).filter(Boolean);
|
|
||||||
|
|
||||||
results.forEach((convo, i) => {
|
|
||||||
const page = Math.floor(i / pageSize) + 1;
|
|
||||||
if (!cache[page]) {
|
|
||||||
cache[page] = [];
|
|
||||||
}
|
|
||||||
cache[page].push(convo);
|
|
||||||
convoMap[convo.conversationId] = convo;
|
convoMap[convo.conversationId] = convo;
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalPages = Math.ceil(results.length / pageSize);
|
return { conversations: limited, nextCursor, convoMap };
|
||||||
cache.pages = totalPages;
|
|
||||||
cache.pageSize = pageSize;
|
|
||||||
return {
|
|
||||||
cache,
|
|
||||||
conversations: cache[pageNumber] || [],
|
|
||||||
pages: totalPages || 1,
|
|
||||||
pageNumber,
|
|
||||||
pageSize,
|
|
||||||
convoMap,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[getConvosQueried] Error getting conversations', error);
|
logger.error('[getConvosQueried] Error getting conversations', error);
|
||||||
return { message: 'Error fetching conversations' };
|
return { message: 'Error fetching conversations' };
|
||||||
@@ -257,10 +286,26 @@ module.exports = {
|
|||||||
* logger.error(result); // { n: 5, ok: 1, deletedCount: 5, messages: { n: 10, ok: 1, deletedCount: 10 } }
|
* logger.error(result); // { n: 5, ok: 1, deletedCount: 5, messages: { n: 10, ok: 1, deletedCount: 10 } }
|
||||||
*/
|
*/
|
||||||
deleteConvos: async (user, filter) => {
|
deleteConvos: async (user, filter) => {
|
||||||
let toRemove = await Conversation.find({ ...filter, user }).select('conversationId');
|
try {
|
||||||
const ids = toRemove.map((instance) => instance.conversationId);
|
const userFilter = { ...filter, user };
|
||||||
let deleteCount = await Conversation.deleteMany({ ...filter, user });
|
|
||||||
deleteCount.messages = await deleteMessages({ conversationId: { $in: ids } });
|
const conversations = await Conversation.find(userFilter).select('conversationId');
|
||||||
return deleteCount;
|
const conversationIds = conversations.map((c) => c.conversationId);
|
||||||
|
|
||||||
|
if (!conversationIds.length) {
|
||||||
|
throw new Error('Conversation not found or already deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteConvoResult = await Conversation.deleteMany(userFilter);
|
||||||
|
|
||||||
|
const deleteMessagesResult = await deleteMessages({
|
||||||
|
conversationId: { $in: conversationIds },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...deleteConvoResult, messages: deleteMessagesResult };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[deleteConvos] Error deleting conversations and messages', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
|
const { EToolResources } = require('librechat-data-provider');
|
||||||
const { fileSchema } = require('@librechat/data-schemas');
|
const { fileSchema } = require('@librechat/data-schemas');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
@@ -8,7 +9,7 @@ const File = mongoose.model('File', fileSchema);
|
|||||||
* Finds a file by its file_id with additional query options.
|
* Finds a file by its file_id with additional query options.
|
||||||
* @param {string} file_id - The unique identifier of the file.
|
* @param {string} file_id - The unique identifier of the file.
|
||||||
* @param {object} options - Query options for filtering, projection, etc.
|
* @param {object} options - Query options for filtering, projection, etc.
|
||||||
* @returns {Promise<IMongoFile>} A promise that resolves to the file document or null.
|
* @returns {Promise<MongoFile>} A promise that resolves to the file document or null.
|
||||||
*/
|
*/
|
||||||
const findFileById = async (file_id, options = {}) => {
|
const findFileById = async (file_id, options = {}) => {
|
||||||
return await File.findOne({ file_id, ...options }).lean();
|
return await File.findOne({ file_id, ...options }).lean();
|
||||||
@@ -20,7 +21,7 @@ const findFileById = async (file_id, options = {}) => {
|
|||||||
* @param {Object} [_sortOptions] - Optional sort parameters.
|
* @param {Object} [_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.
|
||||||
* @returns {Promise<Array<IMongoFile>>} A promise that resolves to an array of file documents.
|
* @returns {Promise<Array<MongoFile>>} A promise that resolves to an array of file documents.
|
||||||
*/
|
*/
|
||||||
const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }) => {
|
const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }) => {
|
||||||
const sortOptions = { updatedAt: -1, ..._sortOptions };
|
const sortOptions = { updatedAt: -1, ..._sortOptions };
|
||||||
@@ -30,9 +31,10 @@ const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }) => {
|
|||||||
/**
|
/**
|
||||||
* Retrieves tool files (files that are embedded or have a fileIdentifier) from an array of file IDs
|
* Retrieves tool files (files that are embedded or have a fileIdentifier) from an array of file IDs
|
||||||
* @param {string[]} fileIds - Array of file_id strings to search for
|
* @param {string[]} fileIds - Array of file_id strings to search for
|
||||||
* @returns {Promise<Array<IMongoFile>>} Files that match the criteria
|
* @param {Set<EToolResources>} toolResourceSet - Optional filter for tool resources
|
||||||
|
* @returns {Promise<Array<MongoFile>>} Files that match the criteria
|
||||||
*/
|
*/
|
||||||
const getToolFilesByIds = async (fileIds) => {
|
const getToolFilesByIds = async (fileIds, toolResourceSet) => {
|
||||||
if (!fileIds || !fileIds.length) {
|
if (!fileIds || !fileIds.length) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -40,9 +42,19 @@ const getToolFilesByIds = async (fileIds) => {
|
|||||||
try {
|
try {
|
||||||
const filter = {
|
const filter = {
|
||||||
file_id: { $in: fileIds },
|
file_id: { $in: fileIds },
|
||||||
$or: [{ embedded: true }, { 'metadata.fileIdentifier': { $exists: true } }],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (toolResourceSet.size) {
|
||||||
|
filter.$or = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolResourceSet.has(EToolResources.file_search)) {
|
||||||
|
filter.$or.push({ embedded: true });
|
||||||
|
}
|
||||||
|
if (toolResourceSet.has(EToolResources.execute_code)) {
|
||||||
|
filter.$or.push({ 'metadata.fileIdentifier': { $exists: true } });
|
||||||
|
}
|
||||||
|
|
||||||
const selectFields = { text: 0 };
|
const selectFields = { text: 0 };
|
||||||
const sortOptions = { updatedAt: -1 };
|
const sortOptions = { updatedAt: -1 };
|
||||||
|
|
||||||
@@ -55,9 +67,9 @@ const getToolFilesByIds = async (fileIds) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new file with a TTL of 1 hour.
|
* Creates a new file with a TTL of 1 hour.
|
||||||
* @param {IMongoFile} data - The file data to be created, must contain file_id.
|
* @param {MongoFile} data - The file data to be created, must contain file_id.
|
||||||
* @param {boolean} disableTTL - Whether to disable the TTL.
|
* @param {boolean} disableTTL - Whether to disable the TTL.
|
||||||
* @returns {Promise<IMongoFile>} A promise that resolves to the created file document.
|
* @returns {Promise<MongoFile>} A promise that resolves to the created file document.
|
||||||
*/
|
*/
|
||||||
const createFile = async (data, disableTTL) => {
|
const createFile = async (data, disableTTL) => {
|
||||||
const fileData = {
|
const fileData = {
|
||||||
@@ -77,8 +89,8 @@ const createFile = async (data, disableTTL) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates a file identified by file_id with new data and removes the TTL.
|
* Updates a file identified by file_id with new data and removes the TTL.
|
||||||
* @param {IMongoFile} data - The data to update, must contain file_id.
|
* @param {MongoFile} data - The data to update, must contain file_id.
|
||||||
* @returns {Promise<IMongoFile>} A promise that resolves to the updated file document.
|
* @returns {Promise<MongoFile>} A promise that resolves to the updated file document.
|
||||||
*/
|
*/
|
||||||
const updateFile = async (data) => {
|
const updateFile = async (data) => {
|
||||||
const { file_id, ...update } = data;
|
const { file_id, ...update } = data;
|
||||||
@@ -91,8 +103,8 @@ const updateFile = async (data) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Increments the usage of a file identified by file_id.
|
* Increments the usage of a file identified by file_id.
|
||||||
* @param {IMongoFile} data - The data to update, must contain file_id and the increment value for usage.
|
* @param {MongoFile} data - The data to update, must contain file_id and the increment value for usage.
|
||||||
* @returns {Promise<IMongoFile>} A promise that resolves to the updated file document.
|
* @returns {Promise<MongoFile>} A promise that resolves to the updated file document.
|
||||||
*/
|
*/
|
||||||
const updateFileUsage = async (data) => {
|
const updateFileUsage = async (data) => {
|
||||||
const { file_id, inc = 1 } = data;
|
const { file_id, inc = 1 } = data;
|
||||||
@@ -106,7 +118,7 @@ const updateFileUsage = async (data) => {
|
|||||||
/**
|
/**
|
||||||
* Deletes a file identified by file_id.
|
* Deletes a file identified by file_id.
|
||||||
* @param {string} file_id - The unique identifier of the file to delete.
|
* @param {string} file_id - The unique identifier of the file to delete.
|
||||||
* @returns {Promise<IMongoFile>} A promise that resolves to the deleted file document or null.
|
* @returns {Promise<MongoFile>} A promise that resolves to the deleted file document or null.
|
||||||
*/
|
*/
|
||||||
const deleteFile = async (file_id) => {
|
const deleteFile = async (file_id) => {
|
||||||
return await File.findOneAndDelete({ file_id }).lean();
|
return await File.findOneAndDelete({ file_id }).lean();
|
||||||
@@ -115,7 +127,7 @@ const deleteFile = async (file_id) => {
|
|||||||
/**
|
/**
|
||||||
* Deletes a file identified by a filter.
|
* Deletes a file identified by a filter.
|
||||||
* @param {object} filter - The filter criteria to apply.
|
* @param {object} filter - The filter criteria to apply.
|
||||||
* @returns {Promise<IMongoFile>} A promise that resolves to the deleted file document or null.
|
* @returns {Promise<MongoFile>} A promise that resolves to the deleted file document or null.
|
||||||
*/
|
*/
|
||||||
const deleteFileByFilter = async (filter) => {
|
const deleteFileByFilter = async (filter) => {
|
||||||
return await File.findOneAndDelete(filter).lean();
|
return await File.findOneAndDelete(filter).lean();
|
||||||
|
|||||||
@@ -61,6 +61,14 @@ async function saveMessage(req, params, metadata) {
|
|||||||
update.expiredAt = null;
|
update.expiredAt = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (update.tokenCount != null && isNaN(update.tokenCount)) {
|
||||||
|
logger.warn(
|
||||||
|
`Resetting invalid \`tokenCount\` for message \`${params.messageId}\`: ${update.tokenCount}`,
|
||||||
|
);
|
||||||
|
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
|
||||||
|
update.tokenCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
const message = await Message.findOneAndUpdate(
|
const message = await Message.findOneAndUpdate(
|
||||||
{ messageId: params.messageId, user: req.user.id },
|
{ messageId: params.messageId, user: req.user.id },
|
||||||
update,
|
update,
|
||||||
@@ -97,7 +105,9 @@ async function saveMessage(req, params, metadata) {
|
|||||||
};
|
};
|
||||||
} catch (findError) {
|
} catch (findError) {
|
||||||
// If the findOne also fails, log it but don't crash
|
// If the findOne also fails, log it but don't crash
|
||||||
logger.warn(`Could not retrieve existing message with ID ${params.messageId}: ${findError.message}`);
|
logger.warn(
|
||||||
|
`Could not retrieve existing message with ID ${params.messageId}: ${findError.message}`,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...params,
|
...params,
|
||||||
messageId: params.messageId,
|
messageId: params.messageId,
|
||||||
|
|||||||
@@ -4,13 +4,8 @@ const {
|
|||||||
SystemRoles,
|
SystemRoles,
|
||||||
roleDefaults,
|
roleDefaults,
|
||||||
PermissionTypes,
|
PermissionTypes,
|
||||||
|
permissionsSchema,
|
||||||
removeNullishValues,
|
removeNullishValues,
|
||||||
agentPermissionsSchema,
|
|
||||||
promptPermissionsSchema,
|
|
||||||
runCodePermissionsSchema,
|
|
||||||
bookmarkPermissionsSchema,
|
|
||||||
multiConvoPermissionsSchema,
|
|
||||||
temporaryChatPermissionsSchema,
|
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const getLogStores = require('~/cache/getLogStores');
|
const getLogStores = require('~/cache/getLogStores');
|
||||||
const { roleSchema } = require('@librechat/data-schemas');
|
const { roleSchema } = require('@librechat/data-schemas');
|
||||||
@@ -20,15 +15,16 @@ const Role = mongoose.model('Role', roleSchema);
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve a role by name and convert the found role document to a plain object.
|
* Retrieve a role by name and convert the found role document to a plain object.
|
||||||
* If the role with the given name doesn't exist and the name is a system defined role, create it and return the lean version.
|
* If the role with the given name doesn't exist and the name is a system defined role,
|
||||||
|
* create it and return the lean version.
|
||||||
*
|
*
|
||||||
* @param {string} roleName - The name of the role to find or create.
|
* @param {string} roleName - The name of the role to find or create.
|
||||||
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
|
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
|
||||||
* @returns {Promise<Object>} A plain object representing the role document.
|
* @returns {Promise<Object>} A plain object representing the role document.
|
||||||
*/
|
*/
|
||||||
const getRoleByName = async function (roleName, fieldsToSelect = null) {
|
const getRoleByName = async function (roleName, fieldsToSelect = null) {
|
||||||
|
const cache = getLogStores(CacheKeys.ROLES);
|
||||||
try {
|
try {
|
||||||
const cache = getLogStores(CacheKeys.ROLES);
|
|
||||||
const cachedRole = await cache.get(roleName);
|
const cachedRole = await cache.get(roleName);
|
||||||
if (cachedRole) {
|
if (cachedRole) {
|
||||||
return cachedRole;
|
return cachedRole;
|
||||||
@@ -40,8 +36,7 @@ const getRoleByName = async function (roleName, fieldsToSelect = null) {
|
|||||||
let role = await query.lean().exec();
|
let role = await query.lean().exec();
|
||||||
|
|
||||||
if (!role && SystemRoles[roleName]) {
|
if (!role && SystemRoles[roleName]) {
|
||||||
role = roleDefaults[roleName];
|
role = await new Role(roleDefaults[roleName]).save();
|
||||||
role = await new Role(role).save();
|
|
||||||
await cache.set(roleName, role);
|
await cache.set(roleName, role);
|
||||||
return role.toObject();
|
return role.toObject();
|
||||||
}
|
}
|
||||||
@@ -60,8 +55,8 @@ const getRoleByName = async function (roleName, fieldsToSelect = null) {
|
|||||||
* @returns {Promise<TRole>} Updated role document.
|
* @returns {Promise<TRole>} Updated role document.
|
||||||
*/
|
*/
|
||||||
const updateRoleByName = async function (roleName, updates) {
|
const updateRoleByName = async function (roleName, updates) {
|
||||||
|
const cache = getLogStores(CacheKeys.ROLES);
|
||||||
try {
|
try {
|
||||||
const cache = getLogStores(CacheKeys.ROLES);
|
|
||||||
const role = await Role.findOneAndUpdate(
|
const role = await Role.findOneAndUpdate(
|
||||||
{ name: roleName },
|
{ name: roleName },
|
||||||
{ $set: updates },
|
{ $set: updates },
|
||||||
@@ -77,29 +72,20 @@ const updateRoleByName = async function (roleName, updates) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const permissionSchemas = {
|
|
||||||
[PermissionTypes.AGENTS]: agentPermissionsSchema,
|
|
||||||
[PermissionTypes.PROMPTS]: promptPermissionsSchema,
|
|
||||||
[PermissionTypes.BOOKMARKS]: bookmarkPermissionsSchema,
|
|
||||||
[PermissionTypes.MULTI_CONVO]: multiConvoPermissionsSchema,
|
|
||||||
[PermissionTypes.TEMPORARY_CHAT]: temporaryChatPermissionsSchema,
|
|
||||||
[PermissionTypes.RUN_CODE]: runCodePermissionsSchema,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates access permissions for a specific role and multiple permission types.
|
* Updates access permissions for a specific role and multiple permission types.
|
||||||
* @param {SystemRoles} roleName - The role to update.
|
* @param {string} roleName - The role to update.
|
||||||
* @param {Object.<PermissionTypes, Object.<Permissions, boolean>>} permissionsUpdate - Permissions to update and their values.
|
* @param {Object.<PermissionTypes, Object.<Permissions, boolean>>} permissionsUpdate - Permissions to update and their values.
|
||||||
*/
|
*/
|
||||||
async function updateAccessPermissions(roleName, permissionsUpdate) {
|
async function updateAccessPermissions(roleName, permissionsUpdate) {
|
||||||
|
// Filter and clean the permission updates based on our schema definition.
|
||||||
const updates = {};
|
const updates = {};
|
||||||
for (const [permissionType, permissions] of Object.entries(permissionsUpdate)) {
|
for (const [permissionType, permissions] of Object.entries(permissionsUpdate)) {
|
||||||
if (permissionSchemas[permissionType]) {
|
if (permissionsSchema.shape && permissionsSchema.shape[permissionType]) {
|
||||||
updates[permissionType] = removeNullishValues(permissions);
|
updates[permissionType] = removeNullishValues(permissions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!Object.keys(updates).length) {
|
||||||
if (Object.keys(updates).length === 0) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,26 +95,75 @@ async function updateAccessPermissions(roleName, permissionsUpdate) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedPermissions = {};
|
const currentPermissions = role.permissions || {};
|
||||||
|
const updatedPermissions = { ...currentPermissions };
|
||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
|
|
||||||
|
const unsetFields = {};
|
||||||
|
const permissionTypes = Object.keys(permissionsSchema.shape || {});
|
||||||
|
for (const permType of permissionTypes) {
|
||||||
|
if (role[permType] && typeof role[permType] === 'object') {
|
||||||
|
logger.info(
|
||||||
|
`Migrating '${roleName}' role from old schema: found '${permType}' at top level`,
|
||||||
|
);
|
||||||
|
|
||||||
|
updatedPermissions[permType] = {
|
||||||
|
...updatedPermissions[permType],
|
||||||
|
...role[permType],
|
||||||
|
};
|
||||||
|
|
||||||
|
unsetFields[permType] = 1;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the current updates
|
||||||
for (const [permissionType, permissions] of Object.entries(updates)) {
|
for (const [permissionType, permissions] of Object.entries(updates)) {
|
||||||
const currentPermissions = role[permissionType] || {};
|
const currentTypePermissions = currentPermissions[permissionType] || {};
|
||||||
updatedPermissions[permissionType] = { ...currentPermissions };
|
updatedPermissions[permissionType] = { ...currentTypePermissions };
|
||||||
|
|
||||||
for (const [permission, value] of Object.entries(permissions)) {
|
for (const [permission, value] of Object.entries(permissions)) {
|
||||||
if (currentPermissions[permission] !== value) {
|
if (currentTypePermissions[permission] !== value) {
|
||||||
updatedPermissions[permissionType][permission] = value;
|
updatedPermissions[permissionType][permission] = value;
|
||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
logger.info(
|
logger.info(
|
||||||
`Updating '${roleName}' role ${permissionType} '${permission}' permission from ${currentPermissions[permission]} to: ${value}`,
|
`Updating '${roleName}' role permission '${permissionType}' '${permission}' from ${currentTypePermissions[permission]} to: ${value}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
await updateRoleByName(roleName, updatedPermissions);
|
const updateObj = { permissions: updatedPermissions };
|
||||||
|
|
||||||
|
if (Object.keys(unsetFields).length > 0) {
|
||||||
|
logger.info(
|
||||||
|
`Unsetting old schema fields for '${roleName}' role: ${Object.keys(unsetFields).join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Role.updateOne(
|
||||||
|
{ name: roleName },
|
||||||
|
{
|
||||||
|
$set: updateObj,
|
||||||
|
$unset: unsetFields,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const cache = getLogStores(CacheKeys.ROLES);
|
||||||
|
const updatedRole = await Role.findOne({ name: roleName }).select('-__v').lean().exec();
|
||||||
|
await cache.set(roleName, updatedRole);
|
||||||
|
|
||||||
|
logger.info(`Updated role '${roleName}' and removed old schema fields`);
|
||||||
|
} catch (updateError) {
|
||||||
|
logger.error(`Error during role migration update: ${updateError.message}`);
|
||||||
|
throw updateError;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Standard update if no migration needed
|
||||||
|
await updateRoleByName(roleName, updateObj);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`Updated '${roleName}' role permissions`);
|
logger.info(`Updated '${roleName}' role permissions`);
|
||||||
} else {
|
} else {
|
||||||
logger.info(`No changes needed for '${roleName}' role permissions`);
|
logger.info(`No changes needed for '${roleName}' role permissions`);
|
||||||
@@ -146,34 +181,111 @@ async function updateAccessPermissions(roleName, permissionsUpdate) {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
const initializeRoles = async function () {
|
const initializeRoles = async function () {
|
||||||
const defaultRoles = [SystemRoles.ADMIN, SystemRoles.USER];
|
for (const roleName of [SystemRoles.ADMIN, SystemRoles.USER]) {
|
||||||
|
|
||||||
for (const roleName of defaultRoles) {
|
|
||||||
let role = await Role.findOne({ name: roleName });
|
let role = await Role.findOne({ name: roleName });
|
||||||
|
const defaultPerms = roleDefaults[roleName].permissions;
|
||||||
|
|
||||||
if (!role) {
|
if (!role) {
|
||||||
// Create new role if it doesn't exist
|
// Create new role if it doesn't exist.
|
||||||
role = new Role(roleDefaults[roleName]);
|
role = new Role(roleDefaults[roleName]);
|
||||||
} else {
|
} else {
|
||||||
// Add missing permission types
|
// Ensure role.permissions is defined.
|
||||||
let isUpdated = false;
|
role.permissions = role.permissions || {};
|
||||||
for (const permType of Object.values(PermissionTypes)) {
|
// For each permission type in defaults, add it if missing.
|
||||||
if (!role[permType]) {
|
for (const permType of Object.keys(defaultPerms)) {
|
||||||
role[permType] = roleDefaults[roleName][permType];
|
if (role.permissions[permType] == null) {
|
||||||
isUpdated = true;
|
role.permissions[permType] = defaultPerms[permType];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isUpdated) {
|
|
||||||
await role.save();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
await role.save();
|
await role.save();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrates roles from old schema to new schema structure.
|
||||||
|
* This can be called directly to fix existing roles.
|
||||||
|
*
|
||||||
|
* @param {string} [roleName] - Optional specific role to migrate. If not provided, migrates all roles.
|
||||||
|
* @returns {Promise<number>} Number of roles migrated.
|
||||||
|
*/
|
||||||
|
const migrateRoleSchema = async function (roleName) {
|
||||||
|
try {
|
||||||
|
// Get roles to migrate
|
||||||
|
let roles;
|
||||||
|
if (roleName) {
|
||||||
|
const role = await Role.findOne({ name: roleName });
|
||||||
|
roles = role ? [role] : [];
|
||||||
|
} else {
|
||||||
|
roles = await Role.find({});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Migrating ${roles.length} roles to new schema structure`);
|
||||||
|
let migratedCount = 0;
|
||||||
|
|
||||||
|
for (const role of roles) {
|
||||||
|
const permissionTypes = Object.keys(permissionsSchema.shape || {});
|
||||||
|
const unsetFields = {};
|
||||||
|
let hasOldSchema = false;
|
||||||
|
|
||||||
|
// Check for old schema fields
|
||||||
|
for (const permType of permissionTypes) {
|
||||||
|
if (role[permType] && typeof role[permType] === 'object') {
|
||||||
|
hasOldSchema = true;
|
||||||
|
|
||||||
|
// Ensure permissions object exists
|
||||||
|
role.permissions = role.permissions || {};
|
||||||
|
|
||||||
|
// Migrate permissions from old location to new
|
||||||
|
role.permissions[permType] = {
|
||||||
|
...role.permissions[permType],
|
||||||
|
...role[permType],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mark field for removal
|
||||||
|
unsetFields[permType] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasOldSchema) {
|
||||||
|
try {
|
||||||
|
logger.info(`Migrating role '${role.name}' from old schema structure`);
|
||||||
|
|
||||||
|
// Simple update operation
|
||||||
|
await Role.updateOne(
|
||||||
|
{ _id: role._id },
|
||||||
|
{
|
||||||
|
$set: { permissions: role.permissions },
|
||||||
|
$unset: unsetFields,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refresh cache
|
||||||
|
const cache = getLogStores(CacheKeys.ROLES);
|
||||||
|
const updatedRole = await Role.findById(role._id).lean().exec();
|
||||||
|
await cache.set(role.name, updatedRole);
|
||||||
|
|
||||||
|
migratedCount++;
|
||||||
|
logger.info(`Migrated role '${role.name}'`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to migrate role '${role.name}': ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Migration complete: ${migratedCount} roles migrated`);
|
||||||
|
return migratedCount;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Role schema migration failed: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
Role,
|
Role,
|
||||||
getRoleByName,
|
getRoleByName,
|
||||||
initializeRoles,
|
initializeRoles,
|
||||||
updateRoleByName,
|
updateRoleByName,
|
||||||
updateAccessPermissions,
|
updateAccessPermissions,
|
||||||
|
migrateRoleSchema,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,22 +2,21 @@ const mongoose = require('mongoose');
|
|||||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
const {
|
const {
|
||||||
SystemRoles,
|
SystemRoles,
|
||||||
PermissionTypes,
|
|
||||||
roleDefaults,
|
|
||||||
Permissions,
|
Permissions,
|
||||||
|
roleDefaults,
|
||||||
|
PermissionTypes,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { updateAccessPermissions, initializeRoles } = require('~/models/Role');
|
const { Role, getRoleByName, updateAccessPermissions, initializeRoles } = require('~/models/Role');
|
||||||
const getLogStores = require('~/cache/getLogStores');
|
const getLogStores = require('~/cache/getLogStores');
|
||||||
const { Role } = require('~/models/Role');
|
|
||||||
|
|
||||||
// Mock the cache
|
// Mock the cache
|
||||||
jest.mock('~/cache/getLogStores', () => {
|
jest.mock('~/cache/getLogStores', () =>
|
||||||
return jest.fn().mockReturnValue({
|
jest.fn().mockReturnValue({
|
||||||
get: jest.fn(),
|
get: jest.fn(),
|
||||||
set: jest.fn(),
|
set: jest.fn(),
|
||||||
del: jest.fn(),
|
del: jest.fn(),
|
||||||
});
|
}),
|
||||||
});
|
);
|
||||||
|
|
||||||
let mongoServer;
|
let mongoServer;
|
||||||
|
|
||||||
@@ -41,10 +40,12 @@ describe('updateAccessPermissions', () => {
|
|||||||
it('should update permissions when changes are needed', async () => {
|
it('should update permissions when changes are needed', async () => {
|
||||||
await new Role({
|
await new Role({
|
||||||
name: SystemRoles.USER,
|
name: SystemRoles.USER,
|
||||||
[PermissionTypes.PROMPTS]: {
|
permissions: {
|
||||||
CREATE: true,
|
[PermissionTypes.PROMPTS]: {
|
||||||
USE: true,
|
CREATE: true,
|
||||||
SHARED_GLOBAL: false,
|
USE: true,
|
||||||
|
SHARED_GLOBAL: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}).save();
|
}).save();
|
||||||
|
|
||||||
@@ -56,8 +57,8 @@ describe('updateAccessPermissions', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
||||||
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
||||||
CREATE: true,
|
CREATE: true,
|
||||||
USE: true,
|
USE: true,
|
||||||
SHARED_GLOBAL: true,
|
SHARED_GLOBAL: true,
|
||||||
@@ -67,10 +68,12 @@ describe('updateAccessPermissions', () => {
|
|||||||
it('should not update permissions when no changes are needed', async () => {
|
it('should not update permissions when no changes are needed', async () => {
|
||||||
await new Role({
|
await new Role({
|
||||||
name: SystemRoles.USER,
|
name: SystemRoles.USER,
|
||||||
[PermissionTypes.PROMPTS]: {
|
permissions: {
|
||||||
CREATE: true,
|
[PermissionTypes.PROMPTS]: {
|
||||||
USE: true,
|
CREATE: true,
|
||||||
SHARED_GLOBAL: false,
|
USE: true,
|
||||||
|
SHARED_GLOBAL: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}).save();
|
}).save();
|
||||||
|
|
||||||
@@ -82,8 +85,8 @@ describe('updateAccessPermissions', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
||||||
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
||||||
CREATE: true,
|
CREATE: true,
|
||||||
USE: true,
|
USE: true,
|
||||||
SHARED_GLOBAL: false,
|
SHARED_GLOBAL: false,
|
||||||
@@ -92,11 +95,8 @@ describe('updateAccessPermissions', () => {
|
|||||||
|
|
||||||
it('should handle non-existent roles', async () => {
|
it('should handle non-existent roles', async () => {
|
||||||
await updateAccessPermissions('NON_EXISTENT_ROLE', {
|
await updateAccessPermissions('NON_EXISTENT_ROLE', {
|
||||||
[PermissionTypes.PROMPTS]: {
|
[PermissionTypes.PROMPTS]: { CREATE: true },
|
||||||
CREATE: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const role = await Role.findOne({ name: 'NON_EXISTENT_ROLE' });
|
const role = await Role.findOne({ name: 'NON_EXISTENT_ROLE' });
|
||||||
expect(role).toBeNull();
|
expect(role).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -104,21 +104,21 @@ describe('updateAccessPermissions', () => {
|
|||||||
it('should update only specified permissions', async () => {
|
it('should update only specified permissions', async () => {
|
||||||
await new Role({
|
await new Role({
|
||||||
name: SystemRoles.USER,
|
name: SystemRoles.USER,
|
||||||
[PermissionTypes.PROMPTS]: {
|
permissions: {
|
||||||
CREATE: true,
|
[PermissionTypes.PROMPTS]: {
|
||||||
USE: true,
|
CREATE: true,
|
||||||
SHARED_GLOBAL: false,
|
USE: true,
|
||||||
|
SHARED_GLOBAL: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}).save();
|
}).save();
|
||||||
|
|
||||||
await updateAccessPermissions(SystemRoles.USER, {
|
await updateAccessPermissions(SystemRoles.USER, {
|
||||||
[PermissionTypes.PROMPTS]: {
|
[PermissionTypes.PROMPTS]: { SHARED_GLOBAL: true },
|
||||||
SHARED_GLOBAL: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
||||||
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
||||||
CREATE: true,
|
CREATE: true,
|
||||||
USE: true,
|
USE: true,
|
||||||
SHARED_GLOBAL: true,
|
SHARED_GLOBAL: true,
|
||||||
@@ -128,21 +128,21 @@ describe('updateAccessPermissions', () => {
|
|||||||
it('should handle partial updates', async () => {
|
it('should handle partial updates', async () => {
|
||||||
await new Role({
|
await new Role({
|
||||||
name: SystemRoles.USER,
|
name: SystemRoles.USER,
|
||||||
[PermissionTypes.PROMPTS]: {
|
permissions: {
|
||||||
CREATE: true,
|
[PermissionTypes.PROMPTS]: {
|
||||||
USE: true,
|
CREATE: true,
|
||||||
SHARED_GLOBAL: false,
|
USE: true,
|
||||||
|
SHARED_GLOBAL: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}).save();
|
}).save();
|
||||||
|
|
||||||
await updateAccessPermissions(SystemRoles.USER, {
|
await updateAccessPermissions(SystemRoles.USER, {
|
||||||
[PermissionTypes.PROMPTS]: {
|
[PermissionTypes.PROMPTS]: { USE: false },
|
||||||
USE: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
||||||
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
||||||
CREATE: true,
|
CREATE: true,
|
||||||
USE: false,
|
USE: false,
|
||||||
SHARED_GLOBAL: false,
|
SHARED_GLOBAL: false,
|
||||||
@@ -152,13 +152,9 @@ describe('updateAccessPermissions', () => {
|
|||||||
it('should update multiple permission types at once', async () => {
|
it('should update multiple permission types at once', async () => {
|
||||||
await new Role({
|
await new Role({
|
||||||
name: SystemRoles.USER,
|
name: SystemRoles.USER,
|
||||||
[PermissionTypes.PROMPTS]: {
|
permissions: {
|
||||||
CREATE: true,
|
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARED_GLOBAL: false },
|
||||||
USE: true,
|
[PermissionTypes.BOOKMARKS]: { USE: true },
|
||||||
SHARED_GLOBAL: false,
|
|
||||||
},
|
|
||||||
[PermissionTypes.BOOKMARKS]: {
|
|
||||||
USE: true,
|
|
||||||
},
|
},
|
||||||
}).save();
|
}).save();
|
||||||
|
|
||||||
@@ -167,24 +163,20 @@ describe('updateAccessPermissions', () => {
|
|||||||
[PermissionTypes.BOOKMARKS]: { USE: false },
|
[PermissionTypes.BOOKMARKS]: { USE: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
||||||
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
||||||
CREATE: true,
|
CREATE: true,
|
||||||
USE: false,
|
USE: false,
|
||||||
SHARED_GLOBAL: true,
|
SHARED_GLOBAL: true,
|
||||||
});
|
});
|
||||||
expect(updatedRole[PermissionTypes.BOOKMARKS]).toEqual({
|
expect(updatedRole.permissions[PermissionTypes.BOOKMARKS]).toEqual({ USE: false });
|
||||||
USE: false,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle updates for a single permission type', async () => {
|
it('should handle updates for a single permission type', async () => {
|
||||||
await new Role({
|
await new Role({
|
||||||
name: SystemRoles.USER,
|
name: SystemRoles.USER,
|
||||||
[PermissionTypes.PROMPTS]: {
|
permissions: {
|
||||||
CREATE: true,
|
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARED_GLOBAL: false },
|
||||||
USE: true,
|
|
||||||
SHARED_GLOBAL: false,
|
|
||||||
},
|
},
|
||||||
}).save();
|
}).save();
|
||||||
|
|
||||||
@@ -192,8 +184,8 @@ describe('updateAccessPermissions', () => {
|
|||||||
[PermissionTypes.PROMPTS]: { USE: false, SHARED_GLOBAL: true },
|
[PermissionTypes.PROMPTS]: { USE: false, SHARED_GLOBAL: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
||||||
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
||||||
CREATE: true,
|
CREATE: true,
|
||||||
USE: false,
|
USE: false,
|
||||||
SHARED_GLOBAL: true,
|
SHARED_GLOBAL: true,
|
||||||
@@ -203,33 +195,25 @@ describe('updateAccessPermissions', () => {
|
|||||||
it('should update MULTI_CONVO permissions', async () => {
|
it('should update MULTI_CONVO permissions', async () => {
|
||||||
await new Role({
|
await new Role({
|
||||||
name: SystemRoles.USER,
|
name: SystemRoles.USER,
|
||||||
[PermissionTypes.MULTI_CONVO]: {
|
permissions: {
|
||||||
USE: false,
|
[PermissionTypes.MULTI_CONVO]: { USE: false },
|
||||||
},
|
},
|
||||||
}).save();
|
}).save();
|
||||||
|
|
||||||
await updateAccessPermissions(SystemRoles.USER, {
|
await updateAccessPermissions(SystemRoles.USER, {
|
||||||
[PermissionTypes.MULTI_CONVO]: {
|
[PermissionTypes.MULTI_CONVO]: { USE: true },
|
||||||
USE: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
||||||
expect(updatedRole[PermissionTypes.MULTI_CONVO]).toEqual({
|
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]).toEqual({ USE: true });
|
||||||
USE: true,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update MULTI_CONVO permissions along with other permission types', async () => {
|
it('should update MULTI_CONVO permissions along with other permission types', async () => {
|
||||||
await new Role({
|
await new Role({
|
||||||
name: SystemRoles.USER,
|
name: SystemRoles.USER,
|
||||||
[PermissionTypes.PROMPTS]: {
|
permissions: {
|
||||||
CREATE: true,
|
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARED_GLOBAL: false },
|
||||||
USE: true,
|
[PermissionTypes.MULTI_CONVO]: { USE: false },
|
||||||
SHARED_GLOBAL: false,
|
|
||||||
},
|
|
||||||
[PermissionTypes.MULTI_CONVO]: {
|
|
||||||
USE: false,
|
|
||||||
},
|
},
|
||||||
}).save();
|
}).save();
|
||||||
|
|
||||||
@@ -238,35 +222,29 @@ describe('updateAccessPermissions', () => {
|
|||||||
[PermissionTypes.MULTI_CONVO]: { USE: true },
|
[PermissionTypes.MULTI_CONVO]: { USE: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
||||||
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
||||||
CREATE: true,
|
CREATE: true,
|
||||||
USE: true,
|
USE: true,
|
||||||
SHARED_GLOBAL: true,
|
SHARED_GLOBAL: true,
|
||||||
});
|
});
|
||||||
expect(updatedRole[PermissionTypes.MULTI_CONVO]).toEqual({
|
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]).toEqual({ USE: true });
|
||||||
USE: true,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not update MULTI_CONVO permissions when no changes are needed', async () => {
|
it('should not update MULTI_CONVO permissions when no changes are needed', async () => {
|
||||||
await new Role({
|
await new Role({
|
||||||
name: SystemRoles.USER,
|
name: SystemRoles.USER,
|
||||||
[PermissionTypes.MULTI_CONVO]: {
|
permissions: {
|
||||||
USE: true,
|
[PermissionTypes.MULTI_CONVO]: { USE: true },
|
||||||
},
|
},
|
||||||
}).save();
|
}).save();
|
||||||
|
|
||||||
await updateAccessPermissions(SystemRoles.USER, {
|
await updateAccessPermissions(SystemRoles.USER, {
|
||||||
[PermissionTypes.MULTI_CONVO]: {
|
[PermissionTypes.MULTI_CONVO]: { USE: true },
|
||||||
USE: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
||||||
expect(updatedRole[PermissionTypes.MULTI_CONVO]).toEqual({
|
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]).toEqual({ USE: true });
|
||||||
USE: true,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -278,65 +256,69 @@ describe('initializeRoles', () => {
|
|||||||
it('should create default roles if they do not exist', async () => {
|
it('should create default roles if they do not exist', async () => {
|
||||||
await initializeRoles();
|
await initializeRoles();
|
||||||
|
|
||||||
const adminRole = await Role.findOne({ name: SystemRoles.ADMIN }).lean();
|
const adminRole = await getRoleByName(SystemRoles.ADMIN);
|
||||||
const userRole = await Role.findOne({ name: SystemRoles.USER }).lean();
|
const userRole = await getRoleByName(SystemRoles.USER);
|
||||||
|
|
||||||
expect(adminRole).toBeTruthy();
|
expect(adminRole).toBeTruthy();
|
||||||
expect(userRole).toBeTruthy();
|
expect(userRole).toBeTruthy();
|
||||||
|
|
||||||
// Check if all permission types exist
|
// Check if all permission types exist in the permissions field
|
||||||
Object.values(PermissionTypes).forEach((permType) => {
|
Object.values(PermissionTypes).forEach((permType) => {
|
||||||
expect(adminRole[permType]).toBeDefined();
|
expect(adminRole.permissions[permType]).toBeDefined();
|
||||||
expect(userRole[permType]).toBeDefined();
|
expect(userRole.permissions[permType]).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if permissions match defaults (example for ADMIN role)
|
// Example: Check default values for ADMIN role
|
||||||
expect(adminRole[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBe(true);
|
expect(adminRole.permissions[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBe(true);
|
||||||
expect(adminRole[PermissionTypes.BOOKMARKS].USE).toBe(true);
|
expect(adminRole.permissions[PermissionTypes.BOOKMARKS].USE).toBe(true);
|
||||||
expect(adminRole[PermissionTypes.AGENTS].CREATE).toBe(true);
|
expect(adminRole.permissions[PermissionTypes.AGENTS].CREATE).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not modify existing permissions for existing roles', async () => {
|
it('should not modify existing permissions for existing roles', async () => {
|
||||||
const customUserRole = {
|
const customUserRole = {
|
||||||
name: SystemRoles.USER,
|
name: SystemRoles.USER,
|
||||||
[PermissionTypes.PROMPTS]: {
|
permissions: {
|
||||||
[Permissions.USE]: false,
|
[PermissionTypes.PROMPTS]: {
|
||||||
[Permissions.CREATE]: true,
|
[Permissions.USE]: false,
|
||||||
[Permissions.SHARED_GLOBAL]: true,
|
[Permissions.CREATE]: true,
|
||||||
},
|
[Permissions.SHARED_GLOBAL]: true,
|
||||||
[PermissionTypes.BOOKMARKS]: {
|
},
|
||||||
[Permissions.USE]: false,
|
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
await new Role(customUserRole).save();
|
await new Role(customUserRole).save();
|
||||||
|
|
||||||
await initializeRoles();
|
await initializeRoles();
|
||||||
|
|
||||||
const userRole = await Role.findOne({ name: SystemRoles.USER }).lean();
|
const userRole = await getRoleByName(SystemRoles.USER);
|
||||||
|
expect(userRole.permissions[PermissionTypes.PROMPTS]).toEqual(
|
||||||
expect(userRole[PermissionTypes.PROMPTS]).toEqual(customUserRole[PermissionTypes.PROMPTS]);
|
customUserRole.permissions[PermissionTypes.PROMPTS],
|
||||||
expect(userRole[PermissionTypes.BOOKMARKS]).toEqual(customUserRole[PermissionTypes.BOOKMARKS]);
|
);
|
||||||
expect(userRole[PermissionTypes.AGENTS]).toBeDefined();
|
expect(userRole.permissions[PermissionTypes.BOOKMARKS]).toEqual(
|
||||||
|
customUserRole.permissions[PermissionTypes.BOOKMARKS],
|
||||||
|
);
|
||||||
|
expect(userRole.permissions[PermissionTypes.AGENTS]).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add new permission types to existing roles', async () => {
|
it('should add new permission types to existing roles', async () => {
|
||||||
const partialUserRole = {
|
const partialUserRole = {
|
||||||
name: SystemRoles.USER,
|
name: SystemRoles.USER,
|
||||||
[PermissionTypes.PROMPTS]: roleDefaults[SystemRoles.USER][PermissionTypes.PROMPTS],
|
permissions: {
|
||||||
[PermissionTypes.BOOKMARKS]: roleDefaults[SystemRoles.USER][PermissionTypes.BOOKMARKS],
|
[PermissionTypes.PROMPTS]:
|
||||||
|
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.PROMPTS],
|
||||||
|
[PermissionTypes.BOOKMARKS]:
|
||||||
|
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.BOOKMARKS],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
await new Role(partialUserRole).save();
|
await new Role(partialUserRole).save();
|
||||||
|
|
||||||
await initializeRoles();
|
await initializeRoles();
|
||||||
|
|
||||||
const userRole = await Role.findOne({ name: SystemRoles.USER }).lean();
|
const userRole = await getRoleByName(SystemRoles.USER);
|
||||||
|
expect(userRole.permissions[PermissionTypes.AGENTS]).toBeDefined();
|
||||||
expect(userRole[PermissionTypes.AGENTS]).toBeDefined();
|
expect(userRole.permissions[PermissionTypes.AGENTS].CREATE).toBeDefined();
|
||||||
expect(userRole[PermissionTypes.AGENTS].CREATE).toBeDefined();
|
expect(userRole.permissions[PermissionTypes.AGENTS].USE).toBeDefined();
|
||||||
expect(userRole[PermissionTypes.AGENTS].USE).toBeDefined();
|
expect(userRole.permissions[PermissionTypes.AGENTS].SHARED_GLOBAL).toBeDefined();
|
||||||
expect(userRole[PermissionTypes.AGENTS].SHARED_GLOBAL).toBeDefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle multiple runs without duplicating or modifying data', async () => {
|
it('should handle multiple runs without duplicating or modifying data', async () => {
|
||||||
@@ -349,72 +331,73 @@ describe('initializeRoles', () => {
|
|||||||
expect(adminRoles).toHaveLength(1);
|
expect(adminRoles).toHaveLength(1);
|
||||||
expect(userRoles).toHaveLength(1);
|
expect(userRoles).toHaveLength(1);
|
||||||
|
|
||||||
const adminRole = adminRoles[0].toObject();
|
const adminPerms = adminRoles[0].toObject().permissions;
|
||||||
const userRole = userRoles[0].toObject();
|
const userPerms = userRoles[0].toObject().permissions;
|
||||||
|
|
||||||
// Check if all permission types exist
|
|
||||||
Object.values(PermissionTypes).forEach((permType) => {
|
Object.values(PermissionTypes).forEach((permType) => {
|
||||||
expect(adminRole[permType]).toBeDefined();
|
expect(adminPerms[permType]).toBeDefined();
|
||||||
expect(userRole[permType]).toBeDefined();
|
expect(userPerms[permType]).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update roles with missing permission types from roleDefaults', async () => {
|
it('should update roles with missing permission types from roleDefaults', async () => {
|
||||||
const partialAdminRole = {
|
const partialAdminRole = {
|
||||||
name: SystemRoles.ADMIN,
|
name: SystemRoles.ADMIN,
|
||||||
[PermissionTypes.PROMPTS]: {
|
permissions: {
|
||||||
[Permissions.USE]: false,
|
[PermissionTypes.PROMPTS]: {
|
||||||
[Permissions.CREATE]: false,
|
[Permissions.USE]: false,
|
||||||
[Permissions.SHARED_GLOBAL]: false,
|
[Permissions.CREATE]: false,
|
||||||
|
[Permissions.SHARED_GLOBAL]: false,
|
||||||
|
},
|
||||||
|
[PermissionTypes.BOOKMARKS]:
|
||||||
|
roleDefaults[SystemRoles.ADMIN].permissions[PermissionTypes.BOOKMARKS],
|
||||||
},
|
},
|
||||||
[PermissionTypes.BOOKMARKS]: roleDefaults[SystemRoles.ADMIN][PermissionTypes.BOOKMARKS],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await new Role(partialAdminRole).save();
|
await new Role(partialAdminRole).save();
|
||||||
|
|
||||||
await initializeRoles();
|
await initializeRoles();
|
||||||
|
|
||||||
const adminRole = await Role.findOne({ name: SystemRoles.ADMIN }).lean();
|
const adminRole = await getRoleByName(SystemRoles.ADMIN);
|
||||||
|
expect(adminRole.permissions[PermissionTypes.PROMPTS]).toEqual(
|
||||||
expect(adminRole[PermissionTypes.PROMPTS]).toEqual(partialAdminRole[PermissionTypes.PROMPTS]);
|
partialAdminRole.permissions[PermissionTypes.PROMPTS],
|
||||||
expect(adminRole[PermissionTypes.AGENTS]).toBeDefined();
|
);
|
||||||
expect(adminRole[PermissionTypes.AGENTS].CREATE).toBeDefined();
|
expect(adminRole.permissions[PermissionTypes.AGENTS]).toBeDefined();
|
||||||
expect(adminRole[PermissionTypes.AGENTS].USE).toBeDefined();
|
expect(adminRole.permissions[PermissionTypes.AGENTS].CREATE).toBeDefined();
|
||||||
expect(adminRole[PermissionTypes.AGENTS].SHARED_GLOBAL).toBeDefined();
|
expect(adminRole.permissions[PermissionTypes.AGENTS].USE).toBeDefined();
|
||||||
|
expect(adminRole.permissions[PermissionTypes.AGENTS].SHARED_GLOBAL).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include MULTI_CONVO permissions when creating default roles', async () => {
|
it('should include MULTI_CONVO permissions when creating default roles', async () => {
|
||||||
await initializeRoles();
|
await initializeRoles();
|
||||||
|
|
||||||
const adminRole = await Role.findOne({ name: SystemRoles.ADMIN }).lean();
|
const adminRole = await getRoleByName(SystemRoles.ADMIN);
|
||||||
const userRole = await Role.findOne({ name: SystemRoles.USER }).lean();
|
const userRole = await getRoleByName(SystemRoles.USER);
|
||||||
|
|
||||||
expect(adminRole[PermissionTypes.MULTI_CONVO]).toBeDefined();
|
expect(adminRole.permissions[PermissionTypes.MULTI_CONVO]).toBeDefined();
|
||||||
expect(userRole[PermissionTypes.MULTI_CONVO]).toBeDefined();
|
expect(userRole.permissions[PermissionTypes.MULTI_CONVO]).toBeDefined();
|
||||||
|
expect(adminRole.permissions[PermissionTypes.MULTI_CONVO].USE).toBe(
|
||||||
// Check if MULTI_CONVO permissions match defaults
|
roleDefaults[SystemRoles.ADMIN].permissions[PermissionTypes.MULTI_CONVO].USE,
|
||||||
expect(adminRole[PermissionTypes.MULTI_CONVO].USE).toBe(
|
|
||||||
roleDefaults[SystemRoles.ADMIN][PermissionTypes.MULTI_CONVO].USE,
|
|
||||||
);
|
);
|
||||||
expect(userRole[PermissionTypes.MULTI_CONVO].USE).toBe(
|
expect(userRole.permissions[PermissionTypes.MULTI_CONVO].USE).toBe(
|
||||||
roleDefaults[SystemRoles.USER][PermissionTypes.MULTI_CONVO].USE,
|
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.MULTI_CONVO].USE,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add MULTI_CONVO permissions to existing roles without them', async () => {
|
it('should add MULTI_CONVO permissions to existing roles without them', async () => {
|
||||||
const partialUserRole = {
|
const partialUserRole = {
|
||||||
name: SystemRoles.USER,
|
name: SystemRoles.USER,
|
||||||
[PermissionTypes.PROMPTS]: roleDefaults[SystemRoles.USER][PermissionTypes.PROMPTS],
|
permissions: {
|
||||||
[PermissionTypes.BOOKMARKS]: roleDefaults[SystemRoles.USER][PermissionTypes.BOOKMARKS],
|
[PermissionTypes.PROMPTS]:
|
||||||
|
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.PROMPTS],
|
||||||
|
[PermissionTypes.BOOKMARKS]:
|
||||||
|
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.BOOKMARKS],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
await new Role(partialUserRole).save();
|
await new Role(partialUserRole).save();
|
||||||
|
|
||||||
await initializeRoles();
|
await initializeRoles();
|
||||||
|
|
||||||
const userRole = await Role.findOne({ name: SystemRoles.USER }).lean();
|
const userRole = await getRoleByName(SystemRoles.USER);
|
||||||
|
expect(userRole.permissions[PermissionTypes.MULTI_CONVO]).toBeDefined();
|
||||||
expect(userRole[PermissionTypes.MULTI_CONVO]).toBeDefined();
|
expect(userRole.permissions[PermissionTypes.MULTI_CONVO].USE).toBeDefined();
|
||||||
expect(userRole[PermissionTypes.MULTI_CONVO].USE).toBeDefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ function anonymizeMessages(messages, newConvoId) {
|
|||||||
const newMessageId = anonymizeMessageId(message.messageId);
|
const newMessageId = anonymizeMessageId(message.messageId);
|
||||||
idMap.set(message.messageId, newMessageId);
|
idMap.set(message.messageId, newMessageId);
|
||||||
|
|
||||||
|
const anonymizedAttachments = message.attachments?.map((attachment) => {
|
||||||
|
return {
|
||||||
|
...attachment,
|
||||||
|
messageId: newMessageId,
|
||||||
|
conversationId: newConvoId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
||||||
messageId: newMessageId,
|
messageId: newMessageId,
|
||||||
@@ -61,6 +69,7 @@ function anonymizeMessages(messages, newConvoId) {
|
|||||||
model: message.model?.startsWith('asst_')
|
model: message.model?.startsWith('asst_')
|
||||||
? anonymizeAssistantId(message.model)
|
? anonymizeAssistantId(message.model)
|
||||||
: message.model,
|
: message.model,
|
||||||
|
attachments: anonymizedAttachments,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,10 +76,15 @@ const tokenValues = Object.assign(
|
|||||||
'4k': { prompt: 1.5, completion: 2 },
|
'4k': { prompt: 1.5, completion: 2 },
|
||||||
'16k': { prompt: 3, completion: 4 },
|
'16k': { prompt: 3, completion: 4 },
|
||||||
'gpt-3.5-turbo-1106': { prompt: 1, completion: 2 },
|
'gpt-3.5-turbo-1106': { prompt: 1, completion: 2 },
|
||||||
|
'o4-mini': { prompt: 1.1, completion: 4.4 },
|
||||||
'o3-mini': { prompt: 1.1, completion: 4.4 },
|
'o3-mini': { prompt: 1.1, completion: 4.4 },
|
||||||
|
o3: { prompt: 10, completion: 40 },
|
||||||
'o1-mini': { prompt: 1.1, completion: 4.4 },
|
'o1-mini': { prompt: 1.1, completion: 4.4 },
|
||||||
'o1-preview': { prompt: 15, completion: 60 },
|
'o1-preview': { prompt: 15, completion: 60 },
|
||||||
o1: { prompt: 15, completion: 60 },
|
o1: { prompt: 15, completion: 60 },
|
||||||
|
'gpt-4.1-nano': { prompt: 0.1, completion: 0.4 },
|
||||||
|
'gpt-4.1-mini': { prompt: 0.4, completion: 1.6 },
|
||||||
|
'gpt-4.1': { prompt: 2, completion: 8 },
|
||||||
'gpt-4.5': { prompt: 75, completion: 150 },
|
'gpt-4.5': { prompt: 75, completion: 150 },
|
||||||
'gpt-4o-mini': { prompt: 0.15, completion: 0.6 },
|
'gpt-4o-mini': { prompt: 0.15, completion: 0.6 },
|
||||||
'gpt-4o': { prompt: 2.5, completion: 10 },
|
'gpt-4o': { prompt: 2.5, completion: 10 },
|
||||||
@@ -106,9 +111,15 @@ const tokenValues = Object.assign(
|
|||||||
/* cohere doesn't have rates for the older command models,
|
/* cohere doesn't have rates for the older command models,
|
||||||
so this was from https://artificialanalysis.ai/models/command-light/providers */
|
so this was from https://artificialanalysis.ai/models/command-light/providers */
|
||||||
command: { prompt: 0.38, completion: 0.38 },
|
command: { prompt: 0.38, completion: 0.38 },
|
||||||
|
gemma: { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
|
||||||
|
'gemma-2': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
|
||||||
|
'gemma-3': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
|
||||||
|
'gemma-3-27b': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
|
||||||
'gemini-2.0-flash-lite': { prompt: 0.075, completion: 0.3 },
|
'gemini-2.0-flash-lite': { prompt: 0.075, completion: 0.3 },
|
||||||
'gemini-2.0-flash': { prompt: 0.1, completion: 0.7 },
|
'gemini-2.0-flash': { prompt: 0.1, completion: 0.4 },
|
||||||
'gemini-2.0': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
|
'gemini-2.0': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
|
||||||
|
'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
|
||||||
|
'gemini-2.5-flash': { prompt: 0.15, completion: 3.5 },
|
||||||
'gemini-2.5': { prompt: 0, completion: 0 }, // Free for a period of time
|
'gemini-2.5': { prompt: 0, completion: 0 }, // Free for a period of time
|
||||||
'gemini-1.5-flash-8b': { prompt: 0.075, completion: 0.3 },
|
'gemini-1.5-flash-8b': { prompt: 0.075, completion: 0.3 },
|
||||||
'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 },
|
'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 },
|
||||||
@@ -122,6 +133,10 @@ const tokenValues = Object.assign(
|
|||||||
'grok-2-1212': { prompt: 2.0, completion: 10.0 },
|
'grok-2-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': { prompt: 0.3, completion: 0.5 },
|
||||||
|
'grok-3-fast': { prompt: 5.0, completion: 25.0 },
|
||||||
|
'grok-3': { prompt: 3.0, completion: 15.0 },
|
||||||
'grok-beta': { prompt: 5.0, completion: 15.0 },
|
'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 },
|
||||||
@@ -170,6 +185,14 @@ const getValueKey = (model, endpoint) => {
|
|||||||
return 'gpt-3.5-turbo-1106';
|
return 'gpt-3.5-turbo-1106';
|
||||||
} else if (modelName.includes('gpt-3.5')) {
|
} else if (modelName.includes('gpt-3.5')) {
|
||||||
return '4k';
|
return '4k';
|
||||||
|
} else if (modelName.includes('o4-mini')) {
|
||||||
|
return 'o4-mini';
|
||||||
|
} else if (modelName.includes('o4')) {
|
||||||
|
return 'o4';
|
||||||
|
} else if (modelName.includes('o3-mini')) {
|
||||||
|
return 'o3-mini';
|
||||||
|
} else if (modelName.includes('o3')) {
|
||||||
|
return 'o3';
|
||||||
} else if (modelName.includes('o1-preview')) {
|
} else if (modelName.includes('o1-preview')) {
|
||||||
return 'o1-preview';
|
return 'o1-preview';
|
||||||
} else if (modelName.includes('o1-mini')) {
|
} else if (modelName.includes('o1-mini')) {
|
||||||
@@ -178,6 +201,12 @@ const getValueKey = (model, endpoint) => {
|
|||||||
return 'o1';
|
return 'o1';
|
||||||
} else if (modelName.includes('gpt-4.5')) {
|
} else if (modelName.includes('gpt-4.5')) {
|
||||||
return 'gpt-4.5';
|
return 'gpt-4.5';
|
||||||
|
} else if (modelName.includes('gpt-4.1-nano')) {
|
||||||
|
return 'gpt-4.1-nano';
|
||||||
|
} else if (modelName.includes('gpt-4.1-mini')) {
|
||||||
|
return 'gpt-4.1-mini';
|
||||||
|
} else if (modelName.includes('gpt-4.1')) {
|
||||||
|
return 'gpt-4.1';
|
||||||
} else if (modelName.includes('gpt-4o-2024-05-13')) {
|
} else if (modelName.includes('gpt-4o-2024-05-13')) {
|
||||||
return 'gpt-4o-2024-05-13';
|
return 'gpt-4o-2024-05-13';
|
||||||
} else if (modelName.includes('gpt-4o-mini')) {
|
} else if (modelName.includes('gpt-4o-mini')) {
|
||||||
|
|||||||
@@ -60,6 +60,30 @@ describe('getValueKey', () => {
|
|||||||
expect(getValueKey('gpt-4.5-0125')).toBe('gpt-4.5');
|
expect(getValueKey('gpt-4.5-0125')).toBe('gpt-4.5');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return "gpt-4.1" for model type of "gpt-4.1"', () => {
|
||||||
|
expect(getValueKey('gpt-4.1-preview')).toBe('gpt-4.1');
|
||||||
|
expect(getValueKey('gpt-4.1-2024-08-06')).toBe('gpt-4.1');
|
||||||
|
expect(getValueKey('gpt-4.1-2024-08-06-0718')).toBe('gpt-4.1');
|
||||||
|
expect(getValueKey('openai/gpt-4.1')).toBe('gpt-4.1');
|
||||||
|
expect(getValueKey('openai/gpt-4.1-2024-08-06')).toBe('gpt-4.1');
|
||||||
|
expect(getValueKey('gpt-4.1-turbo')).toBe('gpt-4.1');
|
||||||
|
expect(getValueKey('gpt-4.1-0125')).toBe('gpt-4.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "gpt-4.1-mini" for model type of "gpt-4.1-mini"', () => {
|
||||||
|
expect(getValueKey('gpt-4.1-mini-preview')).toBe('gpt-4.1-mini');
|
||||||
|
expect(getValueKey('gpt-4.1-mini-2024-08-06')).toBe('gpt-4.1-mini');
|
||||||
|
expect(getValueKey('openai/gpt-4.1-mini')).toBe('gpt-4.1-mini');
|
||||||
|
expect(getValueKey('gpt-4.1-mini-0125')).toBe('gpt-4.1-mini');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "gpt-4.1-nano" for model type of "gpt-4.1-nano"', () => {
|
||||||
|
expect(getValueKey('gpt-4.1-nano-preview')).toBe('gpt-4.1-nano');
|
||||||
|
expect(getValueKey('gpt-4.1-nano-2024-08-06')).toBe('gpt-4.1-nano');
|
||||||
|
expect(getValueKey('openai/gpt-4.1-nano')).toBe('gpt-4.1-nano');
|
||||||
|
expect(getValueKey('gpt-4.1-nano-0125')).toBe('gpt-4.1-nano');
|
||||||
|
});
|
||||||
|
|
||||||
it('should return "gpt-4o" for model type of "gpt-4o"', () => {
|
it('should return "gpt-4o" for model type of "gpt-4o"', () => {
|
||||||
expect(getValueKey('gpt-4o-2024-08-06')).toBe('gpt-4o');
|
expect(getValueKey('gpt-4o-2024-08-06')).toBe('gpt-4o');
|
||||||
expect(getValueKey('gpt-4o-2024-08-06-0718')).toBe('gpt-4o');
|
expect(getValueKey('gpt-4o-2024-08-06-0718')).toBe('gpt-4o');
|
||||||
@@ -141,6 +165,15 @@ describe('getMultiplier', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return correct multipliers for o4-mini and o3', () => {
|
||||||
|
['o4-mini', 'o3'].forEach((model) => {
|
||||||
|
const prompt = getMultiplier({ model, tokenType: 'prompt' });
|
||||||
|
const completion = getMultiplier({ model, tokenType: 'completion' });
|
||||||
|
expect(prompt).toBe(tokenValues[model].prompt);
|
||||||
|
expect(completion).toBe(tokenValues[model].completion);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should return defaultRate if tokenType is provided but not found in tokenValues', () => {
|
it('should return defaultRate if tokenType is provided but not found in tokenValues', () => {
|
||||||
expect(getMultiplier({ valueKey: '8k', tokenType: 'unknownType' })).toBe(defaultRate);
|
expect(getMultiplier({ valueKey: '8k', tokenType: 'unknownType' })).toBe(defaultRate);
|
||||||
});
|
});
|
||||||
@@ -185,6 +218,52 @@ describe('getMultiplier', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return the correct multiplier for gpt-4.1', () => {
|
||||||
|
const valueKey = getValueKey('gpt-4.1-2024-08-06');
|
||||||
|
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4.1'].prompt);
|
||||||
|
expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['gpt-4.1'].completion,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'gpt-4.1-preview', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['gpt-4.1'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'openai/gpt-4.1', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['gpt-4.1'].completion,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the correct multiplier for gpt-4.1-mini', () => {
|
||||||
|
const valueKey = getValueKey('gpt-4.1-mini-2024-08-06');
|
||||||
|
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['gpt-4.1-mini'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['gpt-4.1-mini'].completion,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'gpt-4.1-mini-preview', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['gpt-4.1-mini'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'openai/gpt-4.1-mini', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['gpt-4.1-mini'].completion,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the correct multiplier for gpt-4.1-nano', () => {
|
||||||
|
const valueKey = getValueKey('gpt-4.1-nano-2024-08-06');
|
||||||
|
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['gpt-4.1-nano'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['gpt-4.1-nano'].completion,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'gpt-4.1-nano-preview', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['gpt-4.1-nano'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'openai/gpt-4.1-nano', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['gpt-4.1-nano'].completion,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should return the correct multiplier for gpt-4o-mini', () => {
|
it('should return the correct multiplier for gpt-4o-mini', () => {
|
||||||
const valueKey = getValueKey('gpt-4o-mini-2024-07-18');
|
const valueKey = getValueKey('gpt-4o-mini-2024-07-18');
|
||||||
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(
|
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(
|
||||||
@@ -348,9 +427,11 @@ describe('getCacheMultiplier', () => {
|
|||||||
|
|
||||||
it('should derive the valueKey from the model if not provided', () => {
|
it('should derive the valueKey from the model if not provided', () => {
|
||||||
expect(getCacheMultiplier({ cacheType: 'write', model: 'claude-3-5-sonnet-20240620' })).toBe(
|
expect(getCacheMultiplier({ cacheType: 'write', model: 'claude-3-5-sonnet-20240620' })).toBe(
|
||||||
3.75,
|
cacheTokenValues['claude-3-5-sonnet'].write,
|
||||||
|
);
|
||||||
|
expect(getCacheMultiplier({ cacheType: 'read', model: 'claude-3-haiku-20240307' })).toBe(
|
||||||
|
cacheTokenValues['claude-3-haiku'].read,
|
||||||
);
|
);
|
||||||
expect(getCacheMultiplier({ cacheType: 'read', model: 'claude-3-haiku-20240307' })).toBe(0.03);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null if only model or cacheType is missing', () => {
|
it('should return null if only model or cacheType is missing', () => {
|
||||||
@@ -371,10 +452,10 @@ describe('getCacheMultiplier', () => {
|
|||||||
};
|
};
|
||||||
expect(
|
expect(
|
||||||
getCacheMultiplier({ model: 'custom-model', cacheType: 'write', endpointTokenConfig }),
|
getCacheMultiplier({ model: 'custom-model', cacheType: 'write', endpointTokenConfig }),
|
||||||
).toBe(5);
|
).toBe(endpointTokenConfig['custom-model'].write);
|
||||||
expect(
|
expect(
|
||||||
getCacheMultiplier({ model: 'custom-model', cacheType: 'read', endpointTokenConfig }),
|
getCacheMultiplier({ model: 'custom-model', cacheType: 'read', endpointTokenConfig }),
|
||||||
).toBe(1);
|
).toBe(endpointTokenConfig['custom-model'].read);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null if model is not found in endpointTokenConfig', () => {
|
it('should return null if model is not found in endpointTokenConfig', () => {
|
||||||
@@ -395,18 +476,21 @@ describe('getCacheMultiplier', () => {
|
|||||||
model: 'bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0',
|
model: 'bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0',
|
||||||
cacheType: 'write',
|
cacheType: 'write',
|
||||||
}),
|
}),
|
||||||
).toBe(3.75);
|
).toBe(cacheTokenValues['claude-3-5-sonnet'].write);
|
||||||
expect(
|
expect(
|
||||||
getCacheMultiplier({
|
getCacheMultiplier({
|
||||||
model: 'bedrock/anthropic.claude-3-haiku-20240307-v1:0',
|
model: 'bedrock/anthropic.claude-3-haiku-20240307-v1:0',
|
||||||
cacheType: 'read',
|
cacheType: 'read',
|
||||||
}),
|
}),
|
||||||
).toBe(0.03);
|
).toBe(cacheTokenValues['claude-3-haiku'].read);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Google Model Tests', () => {
|
describe('Google Model Tests', () => {
|
||||||
const googleModels = [
|
const googleModels = [
|
||||||
|
'gemini-2.5-pro-preview-05-06',
|
||||||
|
'gemini-2.5-flash-preview-04-17',
|
||||||
|
'gemini-2.5-exp',
|
||||||
'gemini-2.0-flash-lite-preview-02-05',
|
'gemini-2.0-flash-lite-preview-02-05',
|
||||||
'gemini-2.0-flash-001',
|
'gemini-2.0-flash-001',
|
||||||
'gemini-2.0-flash-exp',
|
'gemini-2.0-flash-exp',
|
||||||
@@ -444,6 +528,9 @@ describe('Google Model Tests', () => {
|
|||||||
|
|
||||||
it('should map to the correct model keys', () => {
|
it('should map to the correct model keys', () => {
|
||||||
const expected = {
|
const expected = {
|
||||||
|
'gemini-2.5-pro-preview-05-06': 'gemini-2.5-pro',
|
||||||
|
'gemini-2.5-flash-preview-04-17': 'gemini-2.5-flash',
|
||||||
|
'gemini-2.5-exp': 'gemini-2.5',
|
||||||
'gemini-2.0-flash-lite-preview-02-05': 'gemini-2.0-flash-lite',
|
'gemini-2.0-flash-lite-preview-02-05': 'gemini-2.0-flash-lite',
|
||||||
'gemini-2.0-flash-001': 'gemini-2.0-flash',
|
'gemini-2.0-flash-001': 'gemini-2.0-flash',
|
||||||
'gemini-2.0-flash-exp': 'gemini-2.0-flash',
|
'gemini-2.0-flash-exp': 'gemini-2.0-flash',
|
||||||
@@ -488,24 +575,92 @@ describe('Grok Model Tests - Pricing', () => {
|
|||||||
test('should return correct prompt and completion rates for Grok vision models', () => {
|
test('should return correct prompt and completion rates for Grok vision models', () => {
|
||||||
const models = ['grok-2-vision-1212', 'grok-2-vision', 'grok-2-vision-latest'];
|
const models = ['grok-2-vision-1212', 'grok-2-vision', 'grok-2-vision-latest'];
|
||||||
models.forEach((model) => {
|
models.forEach((model) => {
|
||||||
expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(2.0);
|
expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(
|
||||||
expect(getMultiplier({ model, tokenType: 'completion' })).toBe(10.0);
|
tokenValues['grok-2-vision'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model, tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-2-vision'].completion,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return correct prompt and completion rates for Grok text models', () => {
|
test('should return correct prompt and completion rates for Grok text models', () => {
|
||||||
const models = ['grok-2-1212', 'grok-2', 'grok-2-latest'];
|
const models = ['grok-2-1212', 'grok-2', 'grok-2-latest'];
|
||||||
models.forEach((model) => {
|
models.forEach((model) => {
|
||||||
expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(2.0);
|
expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(tokenValues['grok-2'].prompt);
|
||||||
expect(getMultiplier({ model, tokenType: 'completion' })).toBe(10.0);
|
expect(getMultiplier({ model, tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-2'].completion,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return correct prompt and completion rates for Grok beta models', () => {
|
test('should return correct prompt and completion rates for Grok beta models', () => {
|
||||||
expect(getMultiplier({ model: 'grok-vision-beta', tokenType: 'prompt' })).toBe(5.0);
|
expect(getMultiplier({ model: 'grok-vision-beta', tokenType: 'prompt' })).toBe(
|
||||||
expect(getMultiplier({ model: 'grok-vision-beta', tokenType: 'completion' })).toBe(15.0);
|
tokenValues['grok-vision-beta'].prompt,
|
||||||
expect(getMultiplier({ model: 'grok-beta', tokenType: 'prompt' })).toBe(5.0);
|
);
|
||||||
expect(getMultiplier({ model: 'grok-beta', tokenType: 'completion' })).toBe(15.0);
|
expect(getMultiplier({ model: 'grok-vision-beta', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-vision-beta'].completion,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'grok-beta', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-beta'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'grok-beta', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-beta'].completion,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return correct prompt and completion rates for Grok 3 models', () => {
|
||||||
|
expect(getMultiplier({ model: 'grok-3', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-3'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'grok-3', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-3'].completion,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'grok-3-fast', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-3-fast'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'grok-3-fast', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-3-fast'].completion,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'grok-3-mini', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-3-mini'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'grok-3-mini', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-3-mini'].completion,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'grok-3-mini-fast', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-3-mini-fast'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'grok-3-mini-fast', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-3-mini-fast'].completion,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return correct prompt and completion rates for Grok 3 models with prefixes', () => {
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-3', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-3'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-3', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-3'].completion,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-3-fast', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-3-fast'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-3-fast', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-3-fast'].completion,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-3-mini', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-3-mini'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-3-mini', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-3-mini'].completion,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-3-mini-fast', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-3-mini-fast'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-3-mini-fast', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-3-mini-fast'].completion,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@librechat/backend",
|
"name": "@librechat/backend",
|
||||||
"version": "v0.7.7",
|
"version": "v0.7.8",
|
||||||
"description": "",
|
"description": "",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "echo 'please run this from the root directory'",
|
"start": "echo 'please run this from the root directory'",
|
||||||
@@ -39,17 +39,16 @@
|
|||||||
"@aws-sdk/s3-request-presigner": "^3.758.0",
|
"@aws-sdk/s3-request-presigner": "^3.758.0",
|
||||||
"@azure/identity": "^4.7.0",
|
"@azure/identity": "^4.7.0",
|
||||||
"@azure/search-documents": "^12.0.0",
|
"@azure/search-documents": "^12.0.0",
|
||||||
"@azure/storage-blob": "^12.26.0",
|
"@azure/storage-blob": "^12.27.0",
|
||||||
"@google/generative-ai": "^0.23.0",
|
"@google/generative-ai": "^0.23.0",
|
||||||
"@googleapis/youtube": "^20.0.0",
|
"@googleapis/youtube": "^20.0.0",
|
||||||
"@keyv/mongo": "^2.1.8",
|
"@keyv/redis": "^4.3.3",
|
||||||
"@keyv/redis": "^2.8.1",
|
"@langchain/community": "^0.3.42",
|
||||||
"@langchain/community": "^0.3.34",
|
"@langchain/core": "^0.3.55",
|
||||||
"@langchain/core": "^0.3.40",
|
"@langchain/google-genai": "^0.2.8",
|
||||||
"@langchain/google-genai": "^0.1.11",
|
"@langchain/google-vertexai": "^0.2.8",
|
||||||
"@langchain/google-vertexai": "^0.2.2",
|
|
||||||
"@langchain/textsplitters": "^0.1.0",
|
"@langchain/textsplitters": "^0.1.0",
|
||||||
"@librechat/agents": "^2.3.95",
|
"@librechat/agents": "^2.4.317",
|
||||||
"@librechat/data-schemas": "*",
|
"@librechat/data-schemas": "*",
|
||||||
"@waylaidwanderer/fetch-event-source": "^3.0.1",
|
"@waylaidwanderer/fetch-event-source": "^3.0.1",
|
||||||
"axios": "^1.8.2",
|
"axios": "^1.8.2",
|
||||||
@@ -76,8 +75,8 @@
|
|||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"jsonwebtoken": "^9.0.0",
|
"jsonwebtoken": "^9.0.0",
|
||||||
"keyv": "^4.5.4",
|
"keyv": "^5.3.2",
|
||||||
"keyv-file": "^0.2.0",
|
"keyv-file": "^5.1.2",
|
||||||
"klona": "^2.0.6",
|
"klona": "^2.0.6",
|
||||||
"librechat-data-provider": "*",
|
"librechat-data-provider": "*",
|
||||||
"librechat-mcp": "*",
|
"librechat-mcp": "*",
|
||||||
@@ -87,11 +86,11 @@
|
|||||||
"mime": "^3.0.0",
|
"mime": "^3.0.0",
|
||||||
"module-alias": "^2.2.3",
|
"module-alias": "^2.2.3",
|
||||||
"mongoose": "^8.12.1",
|
"mongoose": "^8.12.1",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^2.0.0",
|
||||||
"nanoid": "^3.3.7",
|
"nanoid": "^3.3.7",
|
||||||
"nodemailer": "^6.9.15",
|
"nodemailer": "^6.9.15",
|
||||||
"ollama": "^0.5.0",
|
"ollama": "^0.5.0",
|
||||||
"openai": "^4.47.1",
|
"openai": "^4.96.2",
|
||||||
"openai-chat-tokens": "^0.2.8",
|
"openai-chat-tokens": "^0.2.8",
|
||||||
"openid-client": "^5.4.2",
|
"openid-client": "^5.4.2",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
@@ -117,6 +116,6 @@
|
|||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"mongodb-memory-server": "^10.1.3",
|
"mongodb-memory-server": "^10.1.3",
|
||||||
"nodemon": "^3.0.3",
|
"nodemon": "^3.0.3",
|
||||||
"supertest": "^7.0.0"
|
"supertest": "^7.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
387
api/server/cleanup.js
Normal file
387
api/server/cleanup.js
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
|
// WeakMap to hold temporary data associated with requests
|
||||||
|
const requestDataMap = new WeakMap();
|
||||||
|
|
||||||
|
const FinalizationRegistry = global.FinalizationRegistry || null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FinalizationRegistry to clean up client objects when they are garbage collected.
|
||||||
|
* This is used to prevent memory leaks and ensure that client objects are
|
||||||
|
* properly disposed of when they are no longer needed.
|
||||||
|
* The registry holds a weak reference to the client object and a cleanup
|
||||||
|
* callback that is called when the client object is garbage collected.
|
||||||
|
* The callback can be used to perform any necessary cleanup operations,
|
||||||
|
* such as removing event listeners or freeing up resources.
|
||||||
|
*/
|
||||||
|
const clientRegistry = FinalizationRegistry
|
||||||
|
? new FinalizationRegistry((heldValue) => {
|
||||||
|
try {
|
||||||
|
// This will run when the client is garbage collected
|
||||||
|
if (heldValue && heldValue.userId) {
|
||||||
|
logger.debug(`[FinalizationRegistry] Cleaning up client for user ${heldValue.userId}`);
|
||||||
|
} else {
|
||||||
|
logger.debug('[FinalizationRegistry] Cleaning up client');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up the client object by removing references to its properties.
|
||||||
|
* This is useful for preventing memory leaks and ensuring that the client
|
||||||
|
* and its properties can be garbage collected when it is no longer needed.
|
||||||
|
*/
|
||||||
|
function disposeClient(client) {
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (client.user) {
|
||||||
|
client.user = null;
|
||||||
|
}
|
||||||
|
if (client.apiKey) {
|
||||||
|
client.apiKey = null;
|
||||||
|
}
|
||||||
|
if (client.azure) {
|
||||||
|
client.azure = null;
|
||||||
|
}
|
||||||
|
if (client.conversationId) {
|
||||||
|
client.conversationId = null;
|
||||||
|
}
|
||||||
|
if (client.responseMessageId) {
|
||||||
|
client.responseMessageId = null;
|
||||||
|
}
|
||||||
|
if (client.message_file_map) {
|
||||||
|
client.message_file_map = null;
|
||||||
|
}
|
||||||
|
if (client.clientName) {
|
||||||
|
client.clientName = null;
|
||||||
|
}
|
||||||
|
if (client.sender) {
|
||||||
|
client.sender = null;
|
||||||
|
}
|
||||||
|
if (client.model) {
|
||||||
|
client.model = null;
|
||||||
|
}
|
||||||
|
if (client.maxContextTokens) {
|
||||||
|
client.maxContextTokens = null;
|
||||||
|
}
|
||||||
|
if (client.contextStrategy) {
|
||||||
|
client.contextStrategy = null;
|
||||||
|
}
|
||||||
|
if (client.currentDateString) {
|
||||||
|
client.currentDateString = null;
|
||||||
|
}
|
||||||
|
if (client.inputTokensKey) {
|
||||||
|
client.inputTokensKey = null;
|
||||||
|
}
|
||||||
|
if (client.outputTokensKey) {
|
||||||
|
client.outputTokensKey = null;
|
||||||
|
}
|
||||||
|
if (client.skipSaveUserMessage !== undefined) {
|
||||||
|
client.skipSaveUserMessage = null;
|
||||||
|
}
|
||||||
|
if (client.visionMode) {
|
||||||
|
client.visionMode = null;
|
||||||
|
}
|
||||||
|
if (client.continued !== undefined) {
|
||||||
|
client.continued = null;
|
||||||
|
}
|
||||||
|
if (client.fetchedConvo !== undefined) {
|
||||||
|
client.fetchedConvo = null;
|
||||||
|
}
|
||||||
|
if (client.previous_summary) {
|
||||||
|
client.previous_summary = null;
|
||||||
|
}
|
||||||
|
if (client.metadata) {
|
||||||
|
client.metadata = null;
|
||||||
|
}
|
||||||
|
if (client.isVisionModel) {
|
||||||
|
client.isVisionModel = null;
|
||||||
|
}
|
||||||
|
if (client.isChatCompletion !== undefined) {
|
||||||
|
client.isChatCompletion = null;
|
||||||
|
}
|
||||||
|
if (client.contextHandlers) {
|
||||||
|
client.contextHandlers = null;
|
||||||
|
}
|
||||||
|
if (client.augmentedPrompt) {
|
||||||
|
client.augmentedPrompt = null;
|
||||||
|
}
|
||||||
|
if (client.systemMessage) {
|
||||||
|
client.systemMessage = null;
|
||||||
|
}
|
||||||
|
if (client.azureEndpoint) {
|
||||||
|
client.azureEndpoint = null;
|
||||||
|
}
|
||||||
|
if (client.langchainProxy) {
|
||||||
|
client.langchainProxy = null;
|
||||||
|
}
|
||||||
|
if (client.isOmni !== undefined) {
|
||||||
|
client.isOmni = null;
|
||||||
|
}
|
||||||
|
if (client.runManager) {
|
||||||
|
client.runManager = null;
|
||||||
|
}
|
||||||
|
// Properties specific to AnthropicClient
|
||||||
|
if (client.message_start) {
|
||||||
|
client.message_start = null;
|
||||||
|
}
|
||||||
|
if (client.message_delta) {
|
||||||
|
client.message_delta = null;
|
||||||
|
}
|
||||||
|
if (client.isClaude3 !== undefined) {
|
||||||
|
client.isClaude3 = null;
|
||||||
|
}
|
||||||
|
if (client.useMessages !== undefined) {
|
||||||
|
client.useMessages = null;
|
||||||
|
}
|
||||||
|
if (client.isLegacyOutput !== undefined) {
|
||||||
|
client.isLegacyOutput = null;
|
||||||
|
}
|
||||||
|
if (client.supportsCacheControl !== undefined) {
|
||||||
|
client.supportsCacheControl = null;
|
||||||
|
}
|
||||||
|
// Properties specific to GoogleClient
|
||||||
|
if (client.serviceKey) {
|
||||||
|
client.serviceKey = null;
|
||||||
|
}
|
||||||
|
if (client.project_id) {
|
||||||
|
client.project_id = null;
|
||||||
|
}
|
||||||
|
if (client.client_email) {
|
||||||
|
client.client_email = null;
|
||||||
|
}
|
||||||
|
if (client.private_key) {
|
||||||
|
client.private_key = null;
|
||||||
|
}
|
||||||
|
if (client.access_token) {
|
||||||
|
client.access_token = null;
|
||||||
|
}
|
||||||
|
if (client.reverseProxyUrl) {
|
||||||
|
client.reverseProxyUrl = null;
|
||||||
|
}
|
||||||
|
if (client.authHeader) {
|
||||||
|
client.authHeader = null;
|
||||||
|
}
|
||||||
|
if (client.isGenerativeModel !== undefined) {
|
||||||
|
client.isGenerativeModel = null;
|
||||||
|
}
|
||||||
|
// Properties specific to OpenAIClient
|
||||||
|
if (client.ChatGPTClient) {
|
||||||
|
client.ChatGPTClient = null;
|
||||||
|
}
|
||||||
|
if (client.completionsUrl) {
|
||||||
|
client.completionsUrl = null;
|
||||||
|
}
|
||||||
|
if (client.shouldSummarize !== undefined) {
|
||||||
|
client.shouldSummarize = null;
|
||||||
|
}
|
||||||
|
if (client.isOllama !== undefined) {
|
||||||
|
client.isOllama = null;
|
||||||
|
}
|
||||||
|
if (client.FORCE_PROMPT !== undefined) {
|
||||||
|
client.FORCE_PROMPT = null;
|
||||||
|
}
|
||||||
|
if (client.isChatGptModel !== undefined) {
|
||||||
|
client.isChatGptModel = null;
|
||||||
|
}
|
||||||
|
if (client.isUnofficialChatGptModel !== undefined) {
|
||||||
|
client.isUnofficialChatGptModel = null;
|
||||||
|
}
|
||||||
|
if (client.useOpenRouter !== undefined) {
|
||||||
|
client.useOpenRouter = null;
|
||||||
|
}
|
||||||
|
if (client.startToken) {
|
||||||
|
client.startToken = null;
|
||||||
|
}
|
||||||
|
if (client.endToken) {
|
||||||
|
client.endToken = null;
|
||||||
|
}
|
||||||
|
if (client.userLabel) {
|
||||||
|
client.userLabel = null;
|
||||||
|
}
|
||||||
|
if (client.chatGptLabel) {
|
||||||
|
client.chatGptLabel = null;
|
||||||
|
}
|
||||||
|
if (client.modelLabel) {
|
||||||
|
client.modelLabel = null;
|
||||||
|
}
|
||||||
|
if (client.modelOptions) {
|
||||||
|
client.modelOptions = null;
|
||||||
|
}
|
||||||
|
if (client.defaultVisionModel) {
|
||||||
|
client.defaultVisionModel = null;
|
||||||
|
}
|
||||||
|
if (client.maxPromptTokens) {
|
||||||
|
client.maxPromptTokens = null;
|
||||||
|
}
|
||||||
|
if (client.maxResponseTokens) {
|
||||||
|
client.maxResponseTokens = null;
|
||||||
|
}
|
||||||
|
if (client.run) {
|
||||||
|
// Break circular references in run
|
||||||
|
if (client.run.Graph) {
|
||||||
|
client.run.Graph.resetValues();
|
||||||
|
client.run.Graph.handlerRegistry = null;
|
||||||
|
client.run.Graph.runId = null;
|
||||||
|
client.run.Graph.tools = null;
|
||||||
|
client.run.Graph.signal = null;
|
||||||
|
client.run.Graph.config = null;
|
||||||
|
client.run.Graph.toolEnd = null;
|
||||||
|
client.run.Graph.toolMap = null;
|
||||||
|
client.run.Graph.provider = null;
|
||||||
|
client.run.Graph.streamBuffer = null;
|
||||||
|
client.run.Graph.clientOptions = null;
|
||||||
|
client.run.Graph.graphState = null;
|
||||||
|
if (client.run.Graph.boundModel?.client) {
|
||||||
|
client.run.Graph.boundModel.client = null;
|
||||||
|
}
|
||||||
|
client.run.Graph.boundModel = null;
|
||||||
|
client.run.Graph.systemMessage = null;
|
||||||
|
client.run.Graph.reasoningKey = null;
|
||||||
|
client.run.Graph.messages = null;
|
||||||
|
client.run.Graph.contentData = null;
|
||||||
|
client.run.Graph.stepKeyIds = null;
|
||||||
|
client.run.Graph.contentIndexMap = null;
|
||||||
|
client.run.Graph.toolCallStepIds = null;
|
||||||
|
client.run.Graph.messageIdsByStepKey = null;
|
||||||
|
client.run.Graph.messageStepHasToolCalls = null;
|
||||||
|
client.run.Graph.prelimMessageIdsByStepKey = null;
|
||||||
|
client.run.Graph.currentTokenType = null;
|
||||||
|
client.run.Graph.lastToken = null;
|
||||||
|
client.run.Graph.tokenTypeSwitch = null;
|
||||||
|
client.run.Graph.indexTokenCountMap = null;
|
||||||
|
client.run.Graph.currentUsage = null;
|
||||||
|
client.run.Graph.tokenCounter = null;
|
||||||
|
client.run.Graph.maxContextTokens = null;
|
||||||
|
client.run.Graph.pruneMessages = null;
|
||||||
|
client.run.Graph.lastStreamCall = null;
|
||||||
|
client.run.Graph.startIndex = null;
|
||||||
|
client.run.Graph = null;
|
||||||
|
}
|
||||||
|
if (client.run.handlerRegistry) {
|
||||||
|
client.run.handlerRegistry = null;
|
||||||
|
}
|
||||||
|
if (client.run.graphRunnable) {
|
||||||
|
if (client.run.graphRunnable.channels) {
|
||||||
|
client.run.graphRunnable.channels = null;
|
||||||
|
}
|
||||||
|
if (client.run.graphRunnable.nodes) {
|
||||||
|
client.run.graphRunnable.nodes = null;
|
||||||
|
}
|
||||||
|
if (client.run.graphRunnable.lc_kwargs) {
|
||||||
|
client.run.graphRunnable.lc_kwargs = null;
|
||||||
|
}
|
||||||
|
if (client.run.graphRunnable.builder?.nodes) {
|
||||||
|
client.run.graphRunnable.builder.nodes = null;
|
||||||
|
client.run.graphRunnable.builder = null;
|
||||||
|
}
|
||||||
|
client.run.graphRunnable = null;
|
||||||
|
}
|
||||||
|
client.run = null;
|
||||||
|
}
|
||||||
|
if (client.sendMessage) {
|
||||||
|
client.sendMessage = null;
|
||||||
|
}
|
||||||
|
if (client.savedMessageIds) {
|
||||||
|
client.savedMessageIds.clear();
|
||||||
|
client.savedMessageIds = null;
|
||||||
|
}
|
||||||
|
if (client.currentMessages) {
|
||||||
|
client.currentMessages = null;
|
||||||
|
}
|
||||||
|
if (client.streamHandler) {
|
||||||
|
client.streamHandler = null;
|
||||||
|
}
|
||||||
|
if (client.contentParts) {
|
||||||
|
client.contentParts = null;
|
||||||
|
}
|
||||||
|
if (client.abortController) {
|
||||||
|
client.abortController = null;
|
||||||
|
}
|
||||||
|
if (client.collectedUsage) {
|
||||||
|
client.collectedUsage = null;
|
||||||
|
}
|
||||||
|
if (client.indexTokenCountMap) {
|
||||||
|
client.indexTokenCountMap = null;
|
||||||
|
}
|
||||||
|
if (client.agentConfigs) {
|
||||||
|
client.agentConfigs = null;
|
||||||
|
}
|
||||||
|
if (client.artifactPromises) {
|
||||||
|
client.artifactPromises = null;
|
||||||
|
}
|
||||||
|
if (client.usage) {
|
||||||
|
client.usage = null;
|
||||||
|
}
|
||||||
|
if (typeof client.dispose === 'function') {
|
||||||
|
client.dispose();
|
||||||
|
}
|
||||||
|
if (client.options) {
|
||||||
|
if (client.options.req) {
|
||||||
|
client.options.req = null;
|
||||||
|
}
|
||||||
|
if (client.options.res) {
|
||||||
|
client.options.res = null;
|
||||||
|
}
|
||||||
|
if (client.options.attachments) {
|
||||||
|
client.options.attachments = null;
|
||||||
|
}
|
||||||
|
if (client.options.agent) {
|
||||||
|
client.options.agent = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client.options = null;
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors during disposal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function processReqData(data = {}, context) {
|
||||||
|
let {
|
||||||
|
abortKey,
|
||||||
|
userMessage,
|
||||||
|
userMessagePromise,
|
||||||
|
responseMessageId,
|
||||||
|
promptTokens,
|
||||||
|
conversationId,
|
||||||
|
userMessageId,
|
||||||
|
} = context;
|
||||||
|
for (const key in data) {
|
||||||
|
if (key === 'userMessage') {
|
||||||
|
userMessage = data[key];
|
||||||
|
userMessageId = data[key].messageId;
|
||||||
|
} else if (key === 'userMessagePromise') {
|
||||||
|
userMessagePromise = data[key];
|
||||||
|
} else if (key === 'responseMessageId') {
|
||||||
|
responseMessageId = data[key];
|
||||||
|
} else if (key === 'promptTokens') {
|
||||||
|
promptTokens = data[key];
|
||||||
|
} else if (key === 'abortKey') {
|
||||||
|
abortKey = data[key];
|
||||||
|
} else if (!conversationId && key === 'conversationId') {
|
||||||
|
conversationId = data[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
abortKey,
|
||||||
|
userMessage,
|
||||||
|
userMessagePromise,
|
||||||
|
responseMessageId,
|
||||||
|
promptTokens,
|
||||||
|
conversationId,
|
||||||
|
userMessageId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
disposeClient,
|
||||||
|
requestDataMap,
|
||||||
|
clientRegistry,
|
||||||
|
processReqData,
|
||||||
|
};
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
const { getResponseSender, Constants } = require('librechat-data-provider');
|
const { getResponseSender, Constants } = require('librechat-data-provider');
|
||||||
const { createAbortController, handleAbortError } = require('~/server/middleware');
|
const {
|
||||||
|
handleAbortError,
|
||||||
|
createAbortController,
|
||||||
|
cleanupAbortController,
|
||||||
|
} = require('~/server/middleware');
|
||||||
|
const {
|
||||||
|
disposeClient,
|
||||||
|
processReqData,
|
||||||
|
clientRegistry,
|
||||||
|
requestDataMap,
|
||||||
|
} = require('~/server/cleanup');
|
||||||
const { sendMessage, createOnProgress } = require('~/server/utils');
|
const { sendMessage, createOnProgress } = require('~/server/utils');
|
||||||
const { saveMessage } = require('~/models');
|
const { saveMessage } = require('~/models');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
@@ -14,90 +24,162 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
|
|||||||
overrideParentMessageId = null,
|
overrideParentMessageId = null,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
let client = null;
|
||||||
|
let abortKey = null;
|
||||||
|
let cleanupHandlers = [];
|
||||||
|
let clientRef = null;
|
||||||
|
|
||||||
logger.debug('[AskController]', {
|
logger.debug('[AskController]', {
|
||||||
text,
|
text,
|
||||||
conversationId,
|
conversationId,
|
||||||
...endpointOption,
|
...endpointOption,
|
||||||
modelsConfig: endpointOption.modelsConfig ? 'exists' : '',
|
modelsConfig: endpointOption?.modelsConfig ? 'exists' : '',
|
||||||
});
|
});
|
||||||
|
|
||||||
let userMessage;
|
let userMessage = null;
|
||||||
let userMessagePromise;
|
let userMessagePromise = null;
|
||||||
let promptTokens;
|
let promptTokens = null;
|
||||||
let userMessageId;
|
let userMessageId = null;
|
||||||
let responseMessageId;
|
let responseMessageId = null;
|
||||||
|
let getAbortData = null;
|
||||||
|
|
||||||
const sender = getResponseSender({
|
const sender = getResponseSender({
|
||||||
...endpointOption,
|
...endpointOption,
|
||||||
model: endpointOption.modelOptions.model,
|
model: endpointOption.modelOptions.model,
|
||||||
modelDisplayLabel,
|
modelDisplayLabel,
|
||||||
});
|
});
|
||||||
const newConvo = !conversationId;
|
const initialConversationId = conversationId;
|
||||||
const user = req.user.id;
|
const newConvo = !initialConversationId;
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
const getReqData = (data = {}) => {
|
let reqDataContext = {
|
||||||
for (let key in data) {
|
userMessage,
|
||||||
if (key === 'userMessage') {
|
userMessagePromise,
|
||||||
userMessage = data[key];
|
responseMessageId,
|
||||||
userMessageId = data[key].messageId;
|
promptTokens,
|
||||||
} else if (key === 'userMessagePromise') {
|
conversationId,
|
||||||
userMessagePromise = data[key];
|
userMessageId,
|
||||||
} else if (key === 'responseMessageId') {
|
|
||||||
responseMessageId = data[key];
|
|
||||||
} else if (key === 'promptTokens') {
|
|
||||||
promptTokens = data[key];
|
|
||||||
} else if (!conversationId && key === 'conversationId') {
|
|
||||||
conversationId = data[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let getText;
|
const updateReqData = (data = {}) => {
|
||||||
|
reqDataContext = processReqData(data, reqDataContext);
|
||||||
|
abortKey = reqDataContext.abortKey;
|
||||||
|
userMessage = reqDataContext.userMessage;
|
||||||
|
userMessagePromise = reqDataContext.userMessagePromise;
|
||||||
|
responseMessageId = reqDataContext.responseMessageId;
|
||||||
|
promptTokens = reqDataContext.promptTokens;
|
||||||
|
conversationId = reqDataContext.conversationId;
|
||||||
|
userMessageId = reqDataContext.userMessageId;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { onProgress: progressCallback, getPartialText } = createOnProgress();
|
||||||
|
|
||||||
|
const performCleanup = () => {
|
||||||
|
logger.debug('[AskController] Performing cleanup');
|
||||||
|
if (Array.isArray(cleanupHandlers)) {
|
||||||
|
for (const handler of cleanupHandlers) {
|
||||||
|
try {
|
||||||
|
if (typeof handler === 'function') {
|
||||||
|
handler();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abortKey) {
|
||||||
|
logger.debug('[AskController] Cleaning up abort controller');
|
||||||
|
cleanupAbortController(abortKey);
|
||||||
|
abortKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client) {
|
||||||
|
disposeClient(client);
|
||||||
|
client = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
reqDataContext = null;
|
||||||
|
userMessage = null;
|
||||||
|
userMessagePromise = null;
|
||||||
|
promptTokens = null;
|
||||||
|
getAbortData = null;
|
||||||
|
progressCallback = null;
|
||||||
|
endpointOption = null;
|
||||||
|
cleanupHandlers = null;
|
||||||
|
addTitle = null;
|
||||||
|
|
||||||
|
if (requestDataMap.has(req)) {
|
||||||
|
requestDataMap.delete(req);
|
||||||
|
}
|
||||||
|
logger.debug('[AskController] Cleanup completed');
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { client } = await initializeClient({ req, res, endpointOption });
|
({ client } = await initializeClient({ req, res, endpointOption }));
|
||||||
const { onProgress: progressCallback, getPartialText } = createOnProgress();
|
if (clientRegistry && client) {
|
||||||
|
clientRegistry.register(client, { userId }, client);
|
||||||
|
}
|
||||||
|
|
||||||
getText = client.getStreamText != null ? client.getStreamText.bind(client) : getPartialText;
|
if (client) {
|
||||||
|
requestDataMap.set(req, { client });
|
||||||
|
}
|
||||||
|
|
||||||
const getAbortData = () => ({
|
clientRef = new WeakRef(client);
|
||||||
sender,
|
|
||||||
conversationId,
|
|
||||||
userMessagePromise,
|
|
||||||
messageId: responseMessageId,
|
|
||||||
parentMessageId: overrideParentMessageId ?? userMessageId,
|
|
||||||
text: getText(),
|
|
||||||
userMessage,
|
|
||||||
promptTokens,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { abortController, onStart } = createAbortController(req, res, getAbortData, getReqData);
|
getAbortData = () => {
|
||||||
|
const currentClient = clientRef?.deref();
|
||||||
|
const currentText =
|
||||||
|
currentClient?.getStreamText != null ? currentClient.getStreamText() : getPartialText();
|
||||||
|
|
||||||
res.on('close', () => {
|
return {
|
||||||
|
sender,
|
||||||
|
conversationId,
|
||||||
|
messageId: reqDataContext.responseMessageId,
|
||||||
|
parentMessageId: overrideParentMessageId ?? userMessageId,
|
||||||
|
text: currentText,
|
||||||
|
userMessage: userMessage,
|
||||||
|
userMessagePromise: userMessagePromise,
|
||||||
|
promptTokens: reqDataContext.promptTokens,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { onStart, abortController } = createAbortController(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
getAbortData,
|
||||||
|
updateReqData,
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeHandler = () => {
|
||||||
logger.debug('[AskController] Request closed');
|
logger.debug('[AskController] Request closed');
|
||||||
if (!abortController) {
|
if (!abortController || abortController.signal.aborted || abortController.requestCompleted) {
|
||||||
return;
|
|
||||||
} else if (abortController.signal.aborted) {
|
|
||||||
return;
|
|
||||||
} else if (abortController.requestCompleted) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
logger.debug('[AskController] Request aborted on close');
|
logger.debug('[AskController] Request aborted on close');
|
||||||
|
};
|
||||||
|
|
||||||
|
res.on('close', closeHandler);
|
||||||
|
cleanupHandlers.push(() => {
|
||||||
|
try {
|
||||||
|
res.removeListener('close', closeHandler);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const messageOptions = {
|
const messageOptions = {
|
||||||
user,
|
user: userId,
|
||||||
parentMessageId,
|
parentMessageId,
|
||||||
conversationId,
|
conversationId: reqDataContext.conversationId,
|
||||||
overrideParentMessageId,
|
overrideParentMessageId,
|
||||||
getReqData,
|
getReqData: updateReqData,
|
||||||
onStart,
|
onStart,
|
||||||
abortController,
|
abortController,
|
||||||
progressCallback,
|
progressCallback,
|
||||||
progressOptions: {
|
progressOptions: {
|
||||||
res,
|
res,
|
||||||
// parentMessageId: overrideParentMessageId || userMessageId,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,59 +187,95 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
|
|||||||
let response = await client.sendMessage(text, messageOptions);
|
let response = await client.sendMessage(text, messageOptions);
|
||||||
response.endpoint = endpointOption.endpoint;
|
response.endpoint = endpointOption.endpoint;
|
||||||
|
|
||||||
const { conversation = {} } = await client.responsePromise;
|
const databasePromise = response.databasePromise;
|
||||||
|
delete response.databasePromise;
|
||||||
|
|
||||||
|
const { conversation: convoData = {} } = await databasePromise;
|
||||||
|
const conversation = { ...convoData };
|
||||||
conversation.title =
|
conversation.title =
|
||||||
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
|
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
|
||||||
|
|
||||||
if (client.options.attachments) {
|
const latestUserMessage = reqDataContext.userMessage;
|
||||||
userMessage.files = client.options.attachments;
|
|
||||||
conversation.model = endpointOption.modelOptions.model;
|
if (client?.options?.attachments && latestUserMessage) {
|
||||||
delete userMessage.image_urls;
|
latestUserMessage.files = client.options.attachments;
|
||||||
|
if (endpointOption?.modelOptions?.model) {
|
||||||
|
conversation.model = endpointOption.modelOptions.model;
|
||||||
|
}
|
||||||
|
delete latestUserMessage.image_urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) {
|
||||||
|
const finalResponseMessage = { ...response };
|
||||||
|
|
||||||
sendMessage(res, {
|
sendMessage(res, {
|
||||||
final: true,
|
final: true,
|
||||||
conversation,
|
conversation,
|
||||||
title: conversation.title,
|
title: conversation.title,
|
||||||
requestMessage: userMessage,
|
requestMessage: latestUserMessage,
|
||||||
responseMessage: response,
|
responseMessage: finalResponseMessage,
|
||||||
});
|
});
|
||||||
res.end();
|
res.end();
|
||||||
|
|
||||||
if (!client.savedMessageIds.has(response.messageId)) {
|
if (client?.savedMessageIds && !client.savedMessageIds.has(response.messageId)) {
|
||||||
await saveMessage(
|
await saveMessage(
|
||||||
req,
|
req,
|
||||||
{ ...response, user },
|
{ ...finalResponseMessage, user: userId },
|
||||||
{ context: 'api/server/controllers/AskController.js - response end' },
|
{ context: 'api/server/controllers/AskController.js - response end' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!client.skipSaveUserMessage) {
|
if (!client?.skipSaveUserMessage && latestUserMessage) {
|
||||||
await saveMessage(req, userMessage, {
|
await saveMessage(req, latestUserMessage, {
|
||||||
context: 'api/server/controllers/AskController.js - don\'t skip saving user message',
|
context: "api/server/controllers/AskController.js - don't skip saving user message",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (addTitle && parentMessageId === Constants.NO_PARENT && newConvo) {
|
if (typeof addTitle === 'function' && parentMessageId === Constants.NO_PARENT && newConvo) {
|
||||||
addTitle(req, {
|
addTitle(req, {
|
||||||
text,
|
text,
|
||||||
response,
|
response: { ...response },
|
||||||
client,
|
client,
|
||||||
});
|
})
|
||||||
|
.then(() => {
|
||||||
|
logger.debug('[AskController] Title generation started');
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.error('[AskController] Error in title generation', err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
logger.debug('[AskController] Title generation completed');
|
||||||
|
performCleanup();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
performCleanup();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const partialText = getText && getText();
|
logger.error('[AskController] Error handling request', error);
|
||||||
|
let partialText = '';
|
||||||
|
try {
|
||||||
|
const currentClient = clientRef?.deref();
|
||||||
|
partialText =
|
||||||
|
currentClient?.getStreamText != null ? currentClient.getStreamText() : getPartialText();
|
||||||
|
} catch (getTextError) {
|
||||||
|
logger.error('[AskController] Error calling getText() during error handling', getTextError);
|
||||||
|
}
|
||||||
|
|
||||||
handleAbortError(res, req, error, {
|
handleAbortError(res, req, error, {
|
||||||
sender,
|
sender,
|
||||||
partialText,
|
partialText,
|
||||||
conversationId,
|
conversationId: reqDataContext.conversationId,
|
||||||
messageId: responseMessageId,
|
messageId: reqDataContext.responseMessageId,
|
||||||
parentMessageId: overrideParentMessageId ?? userMessageId ?? parentMessageId,
|
parentMessageId: overrideParentMessageId ?? reqDataContext.userMessageId ?? parentMessageId,
|
||||||
}).catch((err) => {
|
userMessageId: reqDataContext.userMessageId,
|
||||||
logger.error('[AskController] Error in `handleAbortError`', err);
|
})
|
||||||
});
|
.catch((err) => {
|
||||||
|
logger.error('[AskController] Error in `handleAbortError` during catch block', err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
performCleanup();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
const { getResponseSender } = require('librechat-data-provider');
|
const { getResponseSender } = require('librechat-data-provider');
|
||||||
const { createAbortController, handleAbortError } = require('~/server/middleware');
|
const {
|
||||||
|
handleAbortError,
|
||||||
|
createAbortController,
|
||||||
|
cleanupAbortController,
|
||||||
|
} = require('~/server/middleware');
|
||||||
|
const {
|
||||||
|
disposeClient,
|
||||||
|
processReqData,
|
||||||
|
clientRegistry,
|
||||||
|
requestDataMap,
|
||||||
|
} = require('~/server/cleanup');
|
||||||
const { sendMessage, createOnProgress } = require('~/server/utils');
|
const { sendMessage, createOnProgress } = require('~/server/utils');
|
||||||
const { saveMessage } = require('~/models');
|
const { saveMessage } = require('~/models');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
@@ -17,6 +27,11 @@ const EditController = async (req, res, next, initializeClient) => {
|
|||||||
overrideParentMessageId = null,
|
overrideParentMessageId = null,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
let client = null;
|
||||||
|
let abortKey = null;
|
||||||
|
let cleanupHandlers = [];
|
||||||
|
let clientRef = null; // Declare clientRef here
|
||||||
|
|
||||||
logger.debug('[EditController]', {
|
logger.debug('[EditController]', {
|
||||||
text,
|
text,
|
||||||
generation,
|
generation,
|
||||||
@@ -26,123 +41,205 @@ const EditController = async (req, res, next, initializeClient) => {
|
|||||||
modelsConfig: endpointOption.modelsConfig ? 'exists' : '',
|
modelsConfig: endpointOption.modelsConfig ? 'exists' : '',
|
||||||
});
|
});
|
||||||
|
|
||||||
let userMessage;
|
let userMessage = null;
|
||||||
let userMessagePromise;
|
let userMessagePromise = null;
|
||||||
let promptTokens;
|
let promptTokens = null;
|
||||||
|
let getAbortData = null;
|
||||||
|
|
||||||
const sender = getResponseSender({
|
const sender = getResponseSender({
|
||||||
...endpointOption,
|
...endpointOption,
|
||||||
model: endpointOption.modelOptions.model,
|
model: endpointOption.modelOptions.model,
|
||||||
modelDisplayLabel,
|
modelDisplayLabel,
|
||||||
});
|
});
|
||||||
const userMessageId = parentMessageId;
|
const userMessageId = parentMessageId;
|
||||||
const user = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
const getReqData = (data = {}) => {
|
let reqDataContext = { userMessage, userMessagePromise, responseMessageId, promptTokens };
|
||||||
for (let key in data) {
|
|
||||||
if (key === 'userMessage') {
|
const updateReqData = (data = {}) => {
|
||||||
userMessage = data[key];
|
reqDataContext = processReqData(data, reqDataContext);
|
||||||
} else if (key === 'userMessagePromise') {
|
abortKey = reqDataContext.abortKey;
|
||||||
userMessagePromise = data[key];
|
userMessage = reqDataContext.userMessage;
|
||||||
} else if (key === 'responseMessageId') {
|
userMessagePromise = reqDataContext.userMessagePromise;
|
||||||
responseMessageId = data[key];
|
responseMessageId = reqDataContext.responseMessageId;
|
||||||
} else if (key === 'promptTokens') {
|
promptTokens = reqDataContext.promptTokens;
|
||||||
promptTokens = data[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { onProgress: progressCallback, getPartialText } = createOnProgress({
|
let { onProgress: progressCallback, getPartialText } = createOnProgress({
|
||||||
generation,
|
generation,
|
||||||
});
|
});
|
||||||
|
|
||||||
let getText;
|
const performCleanup = () => {
|
||||||
|
logger.debug('[EditController] Performing cleanup');
|
||||||
|
if (Array.isArray(cleanupHandlers)) {
|
||||||
|
for (const handler of cleanupHandlers) {
|
||||||
|
try {
|
||||||
|
if (typeof handler === 'function') {
|
||||||
|
handler();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abortKey) {
|
||||||
|
logger.debug('[AskController] Cleaning up abort controller');
|
||||||
|
cleanupAbortController(abortKey);
|
||||||
|
abortKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client) {
|
||||||
|
disposeClient(client);
|
||||||
|
client = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
reqDataContext = null;
|
||||||
|
userMessage = null;
|
||||||
|
userMessagePromise = null;
|
||||||
|
promptTokens = null;
|
||||||
|
getAbortData = null;
|
||||||
|
progressCallback = null;
|
||||||
|
endpointOption = null;
|
||||||
|
cleanupHandlers = null;
|
||||||
|
|
||||||
|
if (requestDataMap.has(req)) {
|
||||||
|
requestDataMap.delete(req);
|
||||||
|
}
|
||||||
|
logger.debug('[EditController] Cleanup completed');
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { client } = await initializeClient({ req, res, endpointOption });
|
({ client } = await initializeClient({ req, res, endpointOption }));
|
||||||
|
|
||||||
getText = client.getStreamText != null ? client.getStreamText.bind(client) : getPartialText;
|
if (clientRegistry && client) {
|
||||||
|
clientRegistry.register(client, { userId }, client);
|
||||||
|
}
|
||||||
|
|
||||||
const getAbortData = () => ({
|
if (client) {
|
||||||
conversationId,
|
requestDataMap.set(req, { client });
|
||||||
userMessagePromise,
|
}
|
||||||
messageId: responseMessageId,
|
|
||||||
sender,
|
|
||||||
parentMessageId: overrideParentMessageId ?? userMessageId,
|
|
||||||
text: getText(),
|
|
||||||
userMessage,
|
|
||||||
promptTokens,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { abortController, onStart } = createAbortController(req, res, getAbortData, getReqData);
|
clientRef = new WeakRef(client);
|
||||||
|
|
||||||
res.on('close', () => {
|
getAbortData = () => {
|
||||||
|
const currentClient = clientRef?.deref();
|
||||||
|
const currentText =
|
||||||
|
currentClient?.getStreamText != null ? currentClient.getStreamText() : getPartialText();
|
||||||
|
|
||||||
|
return {
|
||||||
|
sender,
|
||||||
|
conversationId,
|
||||||
|
messageId: reqDataContext.responseMessageId,
|
||||||
|
parentMessageId: overrideParentMessageId ?? userMessageId,
|
||||||
|
text: currentText,
|
||||||
|
userMessage: userMessage,
|
||||||
|
userMessagePromise: userMessagePromise,
|
||||||
|
promptTokens: reqDataContext.promptTokens,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { onStart, abortController } = createAbortController(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
getAbortData,
|
||||||
|
updateReqData,
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeHandler = () => {
|
||||||
logger.debug('[EditController] Request closed');
|
logger.debug('[EditController] Request closed');
|
||||||
if (!abortController) {
|
if (!abortController || abortController.signal.aborted || abortController.requestCompleted) {
|
||||||
return;
|
|
||||||
} else if (abortController.signal.aborted) {
|
|
||||||
return;
|
|
||||||
} else if (abortController.requestCompleted) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
logger.debug('[EditController] Request aborted on close');
|
logger.debug('[EditController] Request aborted on close');
|
||||||
|
};
|
||||||
|
|
||||||
|
res.on('close', closeHandler);
|
||||||
|
cleanupHandlers.push(() => {
|
||||||
|
try {
|
||||||
|
res.removeListener('close', closeHandler);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = await client.sendMessage(text, {
|
let response = await client.sendMessage(text, {
|
||||||
user,
|
user: userId,
|
||||||
generation,
|
generation,
|
||||||
isContinued,
|
isContinued,
|
||||||
isEdited: true,
|
isEdited: true,
|
||||||
conversationId,
|
conversationId,
|
||||||
parentMessageId,
|
parentMessageId,
|
||||||
responseMessageId,
|
responseMessageId: reqDataContext.responseMessageId,
|
||||||
overrideParentMessageId,
|
overrideParentMessageId,
|
||||||
getReqData,
|
getReqData: updateReqData,
|
||||||
onStart,
|
onStart,
|
||||||
abortController,
|
abortController,
|
||||||
progressCallback,
|
progressCallback,
|
||||||
progressOptions: {
|
progressOptions: {
|
||||||
res,
|
res,
|
||||||
// parentMessageId: overrideParentMessageId || userMessageId,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { conversation = {} } = await client.responsePromise;
|
const databasePromise = response.databasePromise;
|
||||||
|
delete response.databasePromise;
|
||||||
|
|
||||||
|
const { conversation: convoData = {} } = await databasePromise;
|
||||||
|
const conversation = { ...convoData };
|
||||||
conversation.title =
|
conversation.title =
|
||||||
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
|
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
|
||||||
|
|
||||||
if (client.options.attachments) {
|
if (client?.options?.attachments && endpointOption?.modelOptions?.model) {
|
||||||
conversation.model = endpointOption.modelOptions.model;
|
conversation.model = endpointOption.modelOptions.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) {
|
||||||
|
const finalUserMessage = reqDataContext.userMessage;
|
||||||
|
const finalResponseMessage = { ...response };
|
||||||
|
|
||||||
sendMessage(res, {
|
sendMessage(res, {
|
||||||
final: true,
|
final: true,
|
||||||
conversation,
|
conversation,
|
||||||
title: conversation.title,
|
title: conversation.title,
|
||||||
requestMessage: userMessage,
|
requestMessage: finalUserMessage,
|
||||||
responseMessage: response,
|
responseMessage: finalResponseMessage,
|
||||||
});
|
});
|
||||||
res.end();
|
res.end();
|
||||||
|
|
||||||
await saveMessage(
|
await saveMessage(
|
||||||
req,
|
req,
|
||||||
{ ...response, user },
|
{ ...finalResponseMessage, user: userId },
|
||||||
{ context: 'api/server/controllers/EditController.js - response end' },
|
{ context: 'api/server/controllers/EditController.js - response end' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
performCleanup();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const partialText = getText();
|
logger.error('[EditController] Error handling request', error);
|
||||||
|
let partialText = '';
|
||||||
|
try {
|
||||||
|
const currentClient = clientRef?.deref();
|
||||||
|
partialText =
|
||||||
|
currentClient?.getStreamText != null ? currentClient.getStreamText() : getPartialText();
|
||||||
|
} catch (getTextError) {
|
||||||
|
logger.error('[EditController] Error calling getText() during error handling', getTextError);
|
||||||
|
}
|
||||||
|
|
||||||
handleAbortError(res, req, error, {
|
handleAbortError(res, req, error, {
|
||||||
sender,
|
sender,
|
||||||
partialText,
|
partialText,
|
||||||
conversationId,
|
conversationId,
|
||||||
messageId: responseMessageId,
|
messageId: reqDataContext.responseMessageId,
|
||||||
parentMessageId: overrideParentMessageId ?? userMessageId ?? parentMessageId,
|
parentMessageId: overrideParentMessageId ?? userMessageId ?? parentMessageId,
|
||||||
}).catch((err) => {
|
userMessageId,
|
||||||
logger.error('[EditController] Error in `handleAbortError`', err);
|
})
|
||||||
});
|
.catch((err) => {
|
||||||
|
logger.error('[EditController] Error in `handleAbortError` during catch block', err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
performCleanup();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const { CacheKeys, AuthType } = require('librechat-data-provider');
|
const { CacheKeys, AuthType } = require('librechat-data-provider');
|
||||||
const { addOpenAPISpecs } = require('~/app/clients/tools/util/addOpenAPISpecs');
|
const { getToolkitKey } = require('~/server/services/ToolService');
|
||||||
const { getCustomConfig } = require('~/server/services/Config');
|
const { getCustomConfig } = require('~/server/services/Config');
|
||||||
const { availableTools } = require('~/app/clients/tools');
|
const { availableTools } = require('~/app/clients/tools');
|
||||||
const { getMCPManager } = require('~/config');
|
const { getMCPManager } = require('~/config');
|
||||||
@@ -69,7 +69,7 @@ const getAvailablePluginsController = async (req, res) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let plugins = await addOpenAPISpecs(authenticatedPlugins);
|
let plugins = authenticatedPlugins;
|
||||||
|
|
||||||
if (includedTools.length > 0) {
|
if (includedTools.length > 0) {
|
||||||
plugins = plugins.filter((plugin) => includedTools.includes(plugin.pluginKey));
|
plugins = plugins.filter((plugin) => includedTools.includes(plugin.pluginKey));
|
||||||
@@ -105,11 +105,11 @@ const getAvailableTools = async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pluginManifest = availableTools;
|
let pluginManifest = availableTools;
|
||||||
const customConfig = await getCustomConfig();
|
const customConfig = await getCustomConfig();
|
||||||
if (customConfig?.mcpServers != null) {
|
if (customConfig?.mcpServers != null) {
|
||||||
const mcpManager = await getMCPManager();
|
const mcpManager = getMCPManager();
|
||||||
await mcpManager.loadManifestTools(pluginManifest);
|
pluginManifest = await mcpManager.loadManifestTools(pluginManifest);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {TPlugin[]} */
|
/** @type {TPlugin[]} */
|
||||||
@@ -128,7 +128,7 @@ const getAvailableTools = async (req, res) => {
|
|||||||
(plugin) =>
|
(plugin) =>
|
||||||
toolDefinitions[plugin.pluginKey] !== undefined ||
|
toolDefinitions[plugin.pluginKey] !== undefined ||
|
||||||
(plugin.toolkit === true &&
|
(plugin.toolkit === true &&
|
||||||
Object.keys(toolDefinitions).some((key) => key.startsWith(`${plugin.pluginKey}_`))),
|
Object.keys(toolDefinitions).some((key) => getToolkitKey(key) === plugin.pluginKey)),
|
||||||
);
|
);
|
||||||
|
|
||||||
await cache.set(CacheKeys.TOOLS, tools);
|
await cache.set(CacheKeys.TOOLS, tools);
|
||||||
|
|||||||
@@ -14,15 +14,6 @@ const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
|||||||
const { saveBase64Image } = require('~/server/services/Files/process');
|
const { saveBase64Image } = require('~/server/services/Files/process');
|
||||||
const { logger, sendEvent } = require('~/config');
|
const { logger, sendEvent } = require('~/config');
|
||||||
|
|
||||||
/** @typedef {import('@librechat/agents').Graph} Graph */
|
|
||||||
/** @typedef {import('@librechat/agents').EventHandler} EventHandler */
|
|
||||||
/** @typedef {import('@librechat/agents').ModelEndData} ModelEndData */
|
|
||||||
/** @typedef {import('@librechat/agents').ToolEndData} ToolEndData */
|
|
||||||
/** @typedef {import('@librechat/agents').ToolEndCallback} ToolEndCallback */
|
|
||||||
/** @typedef {import('@librechat/agents').ChatModelStreamHandler} ChatModelStreamHandler */
|
|
||||||
/** @typedef {import('@librechat/agents').ContentAggregatorResult['aggregateContent']} ContentAggregator */
|
|
||||||
/** @typedef {import('@librechat/agents').GraphEvents} GraphEvents */
|
|
||||||
|
|
||||||
class ModelEndHandler {
|
class ModelEndHandler {
|
||||||
/**
|
/**
|
||||||
* @param {Array<UsageMetadata>} collectedUsage
|
* @param {Array<UsageMetadata>} collectedUsage
|
||||||
@@ -38,7 +29,7 @@ class ModelEndHandler {
|
|||||||
* @param {string} event
|
* @param {string} event
|
||||||
* @param {ModelEndData | undefined} data
|
* @param {ModelEndData | undefined} data
|
||||||
* @param {Record<string, unknown> | undefined} metadata
|
* @param {Record<string, unknown> | undefined} metadata
|
||||||
* @param {Graph} graph
|
* @param {StandardGraph} graph
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
handle(event, data, metadata, graph) {
|
handle(event, data, metadata, graph) {
|
||||||
@@ -61,7 +52,10 @@ class ModelEndHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.collectedUsage.push(usage);
|
this.collectedUsage.push(usage);
|
||||||
if (!graph.clientOptions?.disableStreaming) {
|
const streamingDisabled = !!(
|
||||||
|
graph.clientOptions?.disableStreaming || graph?.boundModel?.disableStreaming
|
||||||
|
);
|
||||||
|
if (!streamingDisabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!data.output.content) {
|
if (!data.output.content) {
|
||||||
@@ -246,7 +240,11 @@ function createToolEndCallback({ req, res, artifactPromises }) {
|
|||||||
if (output.artifact.content) {
|
if (output.artifact.content) {
|
||||||
/** @type {FormattedContent[]} */
|
/** @type {FormattedContent[]} */
|
||||||
const content = output.artifact.content;
|
const content = output.artifact.content;
|
||||||
for (const part of content) {
|
for (let i = 0; i < content.length; i++) {
|
||||||
|
const part = content[i];
|
||||||
|
if (!part) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (part.type !== 'image_url') {
|
if (part.type !== 'image_url') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -254,8 +252,10 @@ function createToolEndCallback({ req, res, artifactPromises }) {
|
|||||||
artifactPromises.push(
|
artifactPromises.push(
|
||||||
(async () => {
|
(async () => {
|
||||||
const filename = `${output.name}_${output.tool_call_id}_img_${nanoid()}`;
|
const filename = `${output.name}_${output.tool_call_id}_img_${nanoid()}`;
|
||||||
|
const file_id = output.artifact.file_ids?.[i];
|
||||||
const file = await saveBase64Image(url, {
|
const file = await saveBase64Image(url, {
|
||||||
req,
|
req,
|
||||||
|
file_id,
|
||||||
filename,
|
filename,
|
||||||
endpoint: metadata.provider,
|
endpoint: metadata.provider,
|
||||||
context: FileContext.image_generation,
|
context: FileContext.image_generation,
|
||||||
|
|||||||
@@ -20,11 +20,9 @@ const {
|
|||||||
const {
|
const {
|
||||||
Constants,
|
Constants,
|
||||||
VisionModes,
|
VisionModes,
|
||||||
openAISchema,
|
|
||||||
ContentTypes,
|
ContentTypes,
|
||||||
EModelEndpoint,
|
EModelEndpoint,
|
||||||
KnownEndpoints,
|
KnownEndpoints,
|
||||||
anthropicSchema,
|
|
||||||
isAgentsEndpoint,
|
isAgentsEndpoint,
|
||||||
AgentCapabilities,
|
AgentCapabilities,
|
||||||
bedrockInputSchema,
|
bedrockInputSchema,
|
||||||
@@ -35,6 +33,7 @@ const { addCacheControl, createContextHandlers } = require('~/app/clients/prompt
|
|||||||
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
||||||
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
|
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
|
||||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||||
|
const initOpenAI = require('~/server/services/Endpoints/openAI/initialize');
|
||||||
const Tokenizer = require('~/server/services/Tokenizer');
|
const Tokenizer = require('~/server/services/Tokenizer');
|
||||||
const BaseClient = require('~/app/clients/BaseClient');
|
const BaseClient = require('~/app/clients/BaseClient');
|
||||||
const { logger, sendEvent } = require('~/config');
|
const { logger, sendEvent } = require('~/config');
|
||||||
@@ -43,21 +42,43 @@ const { createRun } = require('./run');
|
|||||||
/** @typedef {import('@librechat/agents').MessageContentComplex} MessageContentComplex */
|
/** @typedef {import('@librechat/agents').MessageContentComplex} MessageContentComplex */
|
||||||
/** @typedef {import('@langchain/core/runnables').RunnableConfig} RunnableConfig */
|
/** @typedef {import('@langchain/core/runnables').RunnableConfig} RunnableConfig */
|
||||||
|
|
||||||
const providerParsers = {
|
/**
|
||||||
[EModelEndpoint.openAI]: openAISchema.parse,
|
* @param {ServerRequest} req
|
||||||
[EModelEndpoint.azureOpenAI]: openAISchema.parse,
|
* @param {Agent} agent
|
||||||
[EModelEndpoint.anthropic]: anthropicSchema.parse,
|
* @param {string} endpoint
|
||||||
[EModelEndpoint.bedrock]: bedrockInputSchema.parse,
|
*/
|
||||||
|
const payloadParser = ({ req, agent, endpoint }) => {
|
||||||
|
if (isAgentsEndpoint(endpoint)) {
|
||||||
|
return { model: undefined };
|
||||||
|
} else if (endpoint === EModelEndpoint.bedrock) {
|
||||||
|
return bedrockInputSchema.parse(agent.model_parameters);
|
||||||
|
}
|
||||||
|
return req.body.endpointOption.model_parameters;
|
||||||
};
|
};
|
||||||
|
|
||||||
const legacyContentEndpoints = new Set([KnownEndpoints.groq, KnownEndpoints.deepseek]);
|
const legacyContentEndpoints = new Set([KnownEndpoints.groq, KnownEndpoints.deepseek]);
|
||||||
|
|
||||||
const noSystemModelRegex = [/\bo1\b/gi];
|
const noSystemModelRegex = [/\b(o1-preview|o1-mini|amazon\.titan-text)\b/gi];
|
||||||
|
|
||||||
// const { processMemory, memoryInstructions } = require('~/server/services/Endpoints/agents/memory');
|
// const { processMemory, memoryInstructions } = require('~/server/services/Endpoints/agents/memory');
|
||||||
// const { getFormattedMemories } = require('~/models/Memory');
|
// const { getFormattedMemories } = require('~/models/Memory');
|
||||||
// const { getCurrentDateTime } = require('~/utils');
|
// const { getCurrentDateTime } = require('~/utils');
|
||||||
|
|
||||||
|
function createTokenCounter(encoding) {
|
||||||
|
return (message) => {
|
||||||
|
const countTokens = (text) => Tokenizer.getTokenCount(text, encoding);
|
||||||
|
return getTokenCountForMessage(message, countTokens);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function logToolError(graph, error, toolId) {
|
||||||
|
logger.error(
|
||||||
|
'[api/server/controllers/agents/client.js #chatCompletion] Tool Error',
|
||||||
|
error,
|
||||||
|
toolId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class AgentClient extends BaseClient {
|
class AgentClient extends BaseClient {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
super(null, options);
|
super(null, options);
|
||||||
@@ -127,19 +148,13 @@ class AgentClient extends BaseClient {
|
|||||||
* @param {MongoFile[]} attachments
|
* @param {MongoFile[]} attachments
|
||||||
*/
|
*/
|
||||||
checkVisionRequest(attachments) {
|
checkVisionRequest(attachments) {
|
||||||
logger.info(
|
|
||||||
'[api/server/controllers/agents/client.js #checkVisionRequest] not implemented',
|
|
||||||
attachments,
|
|
||||||
);
|
|
||||||
// if (!attachments) {
|
// if (!attachments) {
|
||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// const availableModels = this.options.modelsConfig?.[this.options.endpoint];
|
// const availableModels = this.options.modelsConfig?.[this.options.endpoint];
|
||||||
// if (!availableModels) {
|
// if (!availableModels) {
|
||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// let visionRequestDetected = false;
|
// let visionRequestDetected = false;
|
||||||
// for (const file of attachments) {
|
// for (const file of attachments) {
|
||||||
// if (file?.type?.includes('image')) {
|
// if (file?.type?.includes('image')) {
|
||||||
@@ -150,13 +165,11 @@ class AgentClient extends BaseClient {
|
|||||||
// if (!visionRequestDetected) {
|
// if (!visionRequestDetected) {
|
||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// this.isVisionModel = validateVisionModel({ model: this.modelOptions.model, availableModels });
|
// this.isVisionModel = validateVisionModel({ model: this.modelOptions.model, availableModels });
|
||||||
// if (this.isVisionModel) {
|
// if (this.isVisionModel) {
|
||||||
// delete this.modelOptions.stop;
|
// delete this.modelOptions.stop;
|
||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// for (const model of availableModels) {
|
// for (const model of availableModels) {
|
||||||
// if (!validateVisionModel({ model, availableModels })) {
|
// if (!validateVisionModel({ model, availableModels })) {
|
||||||
// continue;
|
// continue;
|
||||||
@@ -166,42 +179,31 @@ class AgentClient extends BaseClient {
|
|||||||
// delete this.modelOptions.stop;
|
// delete this.modelOptions.stop;
|
||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// if (!availableModels.includes(this.defaultVisionModel)) {
|
// if (!availableModels.includes(this.defaultVisionModel)) {
|
||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
// if (!validateVisionModel({ model: this.defaultVisionModel, availableModels })) {
|
// if (!validateVisionModel({ model: this.defaultVisionModel, availableModels })) {
|
||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// this.modelOptions.model = this.defaultVisionModel;
|
// this.modelOptions.model = this.defaultVisionModel;
|
||||||
// this.isVisionModel = true;
|
// this.isVisionModel = true;
|
||||||
// delete this.modelOptions.stop;
|
// delete this.modelOptions.stop;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSaveOptions() {
|
getSaveOptions() {
|
||||||
const parseOptions = providerParsers[this.options.endpoint];
|
// TODO:
|
||||||
let runOptions =
|
// would need to be override settings; otherwise, model needs to be undefined
|
||||||
this.options.endpoint === EModelEndpoint.agents
|
// model: this.override.model,
|
||||||
? {
|
// instructions: this.override.instructions,
|
||||||
model: undefined,
|
// additional_instructions: this.override.additional_instructions,
|
||||||
// TODO:
|
let runOptions = {};
|
||||||
// would need to be override settings; otherwise, model needs to be undefined
|
try {
|
||||||
// model: this.override.model,
|
runOptions = payloadParser(this.options);
|
||||||
// instructions: this.override.instructions,
|
} catch (error) {
|
||||||
// additional_instructions: this.override.additional_instructions,
|
logger.error(
|
||||||
}
|
'[api/server/controllers/agents/client.js #getSaveOptions] Error parsing options',
|
||||||
: {};
|
error,
|
||||||
|
);
|
||||||
if (parseOptions) {
|
|
||||||
try {
|
|
||||||
runOptions = parseOptions(this.options.agent.model_parameters);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
'[api/server/controllers/agents/client.js #getSaveOptions] Error parsing options',
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return removeNullishValues(
|
return removeNullishValues(
|
||||||
@@ -352,7 +354,9 @@ class AgentClient extends BaseClient {
|
|||||||
this.contextHandlers?.processFile(file);
|
this.contextHandlers?.processFile(file);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (file.metadata?.fileIdentifier) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// orderedMessages[i].tokenCount += this.calculateImageTokenCost({
|
// orderedMessages[i].tokenCount += this.calculateImageTokenCost({
|
||||||
// width: file.width,
|
// width: file.width,
|
||||||
// height: file.height,
|
// height: file.height,
|
||||||
@@ -539,6 +543,10 @@ class AgentClient extends BaseClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async chatCompletion({ payload, abortController = null }) {
|
async chatCompletion({ payload, abortController = null }) {
|
||||||
|
/** @type {Partial<RunnableConfig> & { version: 'v1' | 'v2'; run_id?: string; streamMode: string }} */
|
||||||
|
let config;
|
||||||
|
/** @type {ReturnType<createRun>} */
|
||||||
|
let run;
|
||||||
try {
|
try {
|
||||||
if (!abortController) {
|
if (!abortController) {
|
||||||
abortController = new AbortController();
|
abortController = new AbortController();
|
||||||
@@ -636,11 +644,11 @@ class AgentClient extends BaseClient {
|
|||||||
/** @type {TCustomConfig['endpoints']['agents']} */
|
/** @type {TCustomConfig['endpoints']['agents']} */
|
||||||
const agentsEConfig = this.options.req.app.locals[EModelEndpoint.agents];
|
const agentsEConfig = this.options.req.app.locals[EModelEndpoint.agents];
|
||||||
|
|
||||||
/** @type {Partial<RunnableConfig> & { version: 'v1' | 'v2'; run_id?: string; streamMode: string }} */
|
config = {
|
||||||
const config = {
|
|
||||||
configurable: {
|
configurable: {
|
||||||
thread_id: this.conversationId,
|
thread_id: this.conversationId,
|
||||||
last_agent_index: this.agentConfigs?.size ?? 0,
|
last_agent_index: this.agentConfigs?.size ?? 0,
|
||||||
|
user_id: this.user ?? this.options.req.user?.id,
|
||||||
hide_sequential_outputs: this.options.agent.hide_sequential_outputs,
|
hide_sequential_outputs: this.options.agent.hide_sequential_outputs,
|
||||||
},
|
},
|
||||||
recursionLimit: agentsEConfig?.recursionLimit,
|
recursionLimit: agentsEConfig?.recursionLimit,
|
||||||
@@ -655,19 +663,10 @@ class AgentClient extends BaseClient {
|
|||||||
this.indexTokenCountMap,
|
this.indexTokenCountMap,
|
||||||
toolSet,
|
toolSet,
|
||||||
);
|
);
|
||||||
if (legacyContentEndpoints.has(this.options.agent.endpoint)) {
|
if (legacyContentEndpoints.has(this.options.agent.endpoint?.toLowerCase())) {
|
||||||
initialMessages = formatContentStrings(initialMessages);
|
initialMessages = formatContentStrings(initialMessages);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {ReturnType<createRun>} */
|
|
||||||
let run;
|
|
||||||
const countTokens = ((text) => this.getTokenCount(text)).bind(this);
|
|
||||||
|
|
||||||
/** @type {(message: BaseMessage) => number} */
|
|
||||||
const tokenCounter = (message) => {
|
|
||||||
return getTokenCountForMessage(message, countTokens);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {Agent} agent
|
* @param {Agent} agent
|
||||||
@@ -719,12 +718,14 @@ class AgentClient extends BaseClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (noSystemMessages === true && systemContent?.length) {
|
if (noSystemMessages === true && systemContent?.length) {
|
||||||
let latestMessage = _messages.pop().content;
|
const latestMessageContent = _messages.pop().content;
|
||||||
if (typeof latestMessage !== 'string') {
|
if (typeof latestMessage !== 'string') {
|
||||||
latestMessage = latestMessage[0].text;
|
latestMessageContent[0].text = [systemContent, latestMessageContent[0].text].join('\n');
|
||||||
|
_messages.push(new HumanMessage({ content: latestMessageContent }));
|
||||||
|
} else {
|
||||||
|
const text = [systemContent, latestMessageContent].join('\n');
|
||||||
|
_messages.push(new HumanMessage(text));
|
||||||
}
|
}
|
||||||
latestMessage = [systemContent, latestMessage].join('\n');
|
|
||||||
_messages.push(new HumanMessage(latestMessage));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let messages = _messages;
|
let messages = _messages;
|
||||||
@@ -771,21 +772,18 @@ class AgentClient extends BaseClient {
|
|||||||
run.Graph.contentData = contentData;
|
run.Graph.contentData = contentData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const encoding = this.getEncoding();
|
||||||
await run.processStream({ messages }, config, {
|
await run.processStream({ messages }, config, {
|
||||||
keepContent: i !== 0,
|
keepContent: i !== 0,
|
||||||
tokenCounter,
|
tokenCounter: createTokenCounter(encoding),
|
||||||
indexTokenCountMap: currentIndexCountMap,
|
indexTokenCountMap: currentIndexCountMap,
|
||||||
maxContextTokens: agent.maxContextTokens,
|
maxContextTokens: agent.maxContextTokens,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
[Callback.TOOL_ERROR]: (graph, error, toolId) => {
|
[Callback.TOOL_ERROR]: logToolError,
|
||||||
logger.error(
|
|
||||||
'[api/server/controllers/agents/client.js #chatCompletion] Tool Error',
|
|
||||||
error,
|
|
||||||
toolId,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
config.signal = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
await runAgent(this.options.agent, initialMessages);
|
await runAgent(this.options.agent, initialMessages);
|
||||||
@@ -813,6 +811,8 @@ class AgentClient extends BaseClient {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const encoding = this.getEncoding();
|
||||||
|
const tokenCounter = createTokenCounter(encoding);
|
||||||
for (const [agentId, agent] of this.agentConfigs) {
|
for (const [agentId, agent] of this.agentConfigs) {
|
||||||
if (abortController.signal.aborted === true) {
|
if (abortController.signal.aborted === true) {
|
||||||
break;
|
break;
|
||||||
@@ -921,19 +921,21 @@ class AgentClient extends BaseClient {
|
|||||||
* @param {string} params.text
|
* @param {string} params.text
|
||||||
* @param {string} params.conversationId
|
* @param {string} params.conversationId
|
||||||
*/
|
*/
|
||||||
async titleConvo({ text }) {
|
async titleConvo({ text, abortController }) {
|
||||||
if (!this.run) {
|
if (!this.run) {
|
||||||
throw new Error('Run not initialized');
|
throw new Error('Run not initialized');
|
||||||
}
|
}
|
||||||
const { handleLLMEnd, collected: collectedMetadata } = createMetadataAggregator();
|
const { handleLLMEnd, collected: collectedMetadata } = createMetadataAggregator();
|
||||||
|
const endpoint = this.options.agent.endpoint;
|
||||||
|
const { req, res } = this.options;
|
||||||
/** @type {import('@librechat/agents').ClientOptions} */
|
/** @type {import('@librechat/agents').ClientOptions} */
|
||||||
const clientOptions = {
|
let clientOptions = {
|
||||||
maxTokens: 75,
|
maxTokens: 75,
|
||||||
};
|
};
|
||||||
let endpointConfig = this.options.req.app.locals[this.options.agent.endpoint];
|
let endpointConfig = req.app.locals[endpoint];
|
||||||
if (!endpointConfig) {
|
if (!endpointConfig) {
|
||||||
try {
|
try {
|
||||||
endpointConfig = await getCustomEndpointConfig(this.options.agent.endpoint);
|
endpointConfig = await getCustomEndpointConfig(endpoint);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'[api/server/controllers/agents/client.js #titleConvo] Error getting custom endpoint config',
|
'[api/server/controllers/agents/client.js #titleConvo] Error getting custom endpoint config',
|
||||||
@@ -948,12 +950,35 @@ class AgentClient extends BaseClient {
|
|||||||
) {
|
) {
|
||||||
clientOptions.model = endpointConfig.titleModel;
|
clientOptions.model = endpointConfig.titleModel;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
endpoint === EModelEndpoint.azureOpenAI &&
|
||||||
|
clientOptions.model &&
|
||||||
|
this.options.agent.model_parameters.model !== clientOptions.model
|
||||||
|
) {
|
||||||
|
clientOptions =
|
||||||
|
(
|
||||||
|
await initOpenAI({
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
optionsOnly: true,
|
||||||
|
overrideModel: clientOptions.model,
|
||||||
|
overrideEndpoint: endpoint,
|
||||||
|
endpointOption: {
|
||||||
|
model_parameters: clientOptions,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)?.llmConfig ?? clientOptions;
|
||||||
|
}
|
||||||
|
if (/\b(o\d)\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
|
||||||
|
delete clientOptions.maxTokens;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const titleResult = await this.run.generateTitle({
|
const titleResult = await this.run.generateTitle({
|
||||||
inputText: text,
|
inputText: text,
|
||||||
contentParts: this.contentParts,
|
contentParts: this.contentParts,
|
||||||
clientOptions,
|
clientOptions,
|
||||||
chainOptions: {
|
chainOptions: {
|
||||||
|
signal: abortController.signal,
|
||||||
callbacks: [
|
callbacks: [
|
||||||
{
|
{
|
||||||
handleLLMEnd,
|
handleLLMEnd,
|
||||||
@@ -979,7 +1004,7 @@ class AgentClient extends BaseClient {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
this.recordCollectedUsage({
|
await this.recordCollectedUsage({
|
||||||
model: clientOptions.model,
|
model: clientOptions.model,
|
||||||
context: 'title',
|
context: 'title',
|
||||||
collectedUsage,
|
collectedUsage,
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
const { Constants } = require('librechat-data-provider');
|
const { Constants } = require('librechat-data-provider');
|
||||||
const { createAbortController, handleAbortError } = require('~/server/middleware');
|
const {
|
||||||
|
handleAbortError,
|
||||||
|
createAbortController,
|
||||||
|
cleanupAbortController,
|
||||||
|
} = require('~/server/middleware');
|
||||||
|
const { disposeClient, clientRegistry, requestDataMap } = require('~/server/cleanup');
|
||||||
const { sendMessage } = require('~/server/utils');
|
const { sendMessage } = require('~/server/utils');
|
||||||
const { saveMessage } = require('~/models');
|
const { saveMessage } = require('~/models');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
@@ -14,16 +19,22 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
|||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
let sender;
|
let sender;
|
||||||
|
let abortKey;
|
||||||
let userMessage;
|
let userMessage;
|
||||||
let promptTokens;
|
let promptTokens;
|
||||||
let userMessageId;
|
let userMessageId;
|
||||||
let responseMessageId;
|
let responseMessageId;
|
||||||
let userMessagePromise;
|
let userMessagePromise;
|
||||||
|
let getAbortData;
|
||||||
|
let client = null;
|
||||||
|
// Initialize as an array
|
||||||
|
let cleanupHandlers = [];
|
||||||
|
|
||||||
const newConvo = !conversationId;
|
const newConvo = !conversationId;
|
||||||
const user = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
const getReqData = (data = {}) => {
|
// Create handler to avoid capturing the entire parent scope
|
||||||
|
let getReqData = (data = {}) => {
|
||||||
for (let key in data) {
|
for (let key in data) {
|
||||||
if (key === 'userMessage') {
|
if (key === 'userMessage') {
|
||||||
userMessage = data[key];
|
userMessage = data[key];
|
||||||
@@ -36,30 +47,96 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
|||||||
promptTokens = data[key];
|
promptTokens = data[key];
|
||||||
} else if (key === 'sender') {
|
} else if (key === 'sender') {
|
||||||
sender = data[key];
|
sender = data[key];
|
||||||
|
} else if (key === 'abortKey') {
|
||||||
|
abortKey = data[key];
|
||||||
} else if (!conversationId && key === 'conversationId') {
|
} else if (!conversationId && key === 'conversationId') {
|
||||||
conversationId = data[key];
|
conversationId = data[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Create a function to handle final cleanup
|
||||||
|
const performCleanup = () => {
|
||||||
|
logger.debug('[AgentController] Performing cleanup');
|
||||||
|
// Make sure cleanupHandlers is an array before iterating
|
||||||
|
if (Array.isArray(cleanupHandlers)) {
|
||||||
|
// Execute all cleanup handlers
|
||||||
|
for (const handler of cleanupHandlers) {
|
||||||
|
try {
|
||||||
|
if (typeof handler === 'function') {
|
||||||
|
handler();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up abort controller
|
||||||
|
if (abortKey) {
|
||||||
|
logger.debug('[AgentController] Cleaning up abort controller');
|
||||||
|
cleanupAbortController(abortKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispose client properly
|
||||||
|
if (client) {
|
||||||
|
disposeClient(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all references
|
||||||
|
client = null;
|
||||||
|
getReqData = null;
|
||||||
|
userMessage = null;
|
||||||
|
getAbortData = null;
|
||||||
|
endpointOption.agent = null;
|
||||||
|
endpointOption = null;
|
||||||
|
cleanupHandlers = null;
|
||||||
|
userMessagePromise = null;
|
||||||
|
|
||||||
|
// Clear request data map
|
||||||
|
if (requestDataMap.has(req)) {
|
||||||
|
requestDataMap.delete(req);
|
||||||
|
}
|
||||||
|
logger.debug('[AgentController] Cleanup completed');
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
/** @type {{ client: TAgentClient }} */
|
/** @type {{ client: TAgentClient }} */
|
||||||
const { client } = await initializeClient({ req, res, endpointOption });
|
const result = await initializeClient({ req, res, endpointOption });
|
||||||
|
client = result.client;
|
||||||
|
|
||||||
const getAbortData = () => ({
|
// Register client with finalization registry if available
|
||||||
sender,
|
if (clientRegistry) {
|
||||||
userMessage,
|
clientRegistry.register(client, { userId }, client);
|
||||||
promptTokens,
|
}
|
||||||
conversationId,
|
|
||||||
userMessagePromise,
|
// Store request data in WeakMap keyed by req object
|
||||||
messageId: responseMessageId,
|
requestDataMap.set(req, { client });
|
||||||
content: client.getContentParts(),
|
|
||||||
parentMessageId: overrideParentMessageId ?? userMessageId,
|
// Use WeakRef to allow GC but still access content if it exists
|
||||||
});
|
const contentRef = new WeakRef(client.contentParts || []);
|
||||||
|
|
||||||
|
// Minimize closure scope - only capture small primitives and WeakRef
|
||||||
|
getAbortData = () => {
|
||||||
|
// Dereference WeakRef each time
|
||||||
|
const content = contentRef.deref();
|
||||||
|
|
||||||
|
return {
|
||||||
|
sender,
|
||||||
|
content: content || [],
|
||||||
|
userMessage,
|
||||||
|
promptTokens,
|
||||||
|
conversationId,
|
||||||
|
userMessagePromise,
|
||||||
|
messageId: responseMessageId,
|
||||||
|
parentMessageId: overrideParentMessageId ?? userMessageId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const { abortController, onStart } = createAbortController(req, res, getAbortData, getReqData);
|
const { abortController, onStart } = createAbortController(req, res, getAbortData, getReqData);
|
||||||
|
|
||||||
res.on('close', () => {
|
// Simple handler to avoid capturing scope
|
||||||
|
const closeHandler = () => {
|
||||||
logger.debug('[AgentController] Request closed');
|
logger.debug('[AgentController] Request closed');
|
||||||
if (!abortController) {
|
if (!abortController) {
|
||||||
return;
|
return;
|
||||||
@@ -71,10 +148,19 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
|||||||
|
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
logger.debug('[AgentController] Request aborted on close');
|
logger.debug('[AgentController] Request aborted on close');
|
||||||
|
};
|
||||||
|
|
||||||
|
res.on('close', closeHandler);
|
||||||
|
cleanupHandlers.push(() => {
|
||||||
|
try {
|
||||||
|
res.removeListener('close', closeHandler);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const messageOptions = {
|
const messageOptions = {
|
||||||
user,
|
user: userId,
|
||||||
onStart,
|
onStart,
|
||||||
getReqData,
|
getReqData,
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -83,69 +169,104 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
|||||||
overrideParentMessageId,
|
overrideParentMessageId,
|
||||||
progressOptions: {
|
progressOptions: {
|
||||||
res,
|
res,
|
||||||
// parentMessageId: overrideParentMessageId || userMessageId,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = await client.sendMessage(text, messageOptions);
|
let response = await client.sendMessage(text, messageOptions);
|
||||||
response.endpoint = endpointOption.endpoint;
|
|
||||||
|
|
||||||
const { conversation = {} } = await client.responsePromise;
|
// Extract what we need and immediately break reference
|
||||||
|
const messageId = response.messageId;
|
||||||
|
const endpoint = endpointOption.endpoint;
|
||||||
|
response.endpoint = endpoint;
|
||||||
|
|
||||||
|
// Store database promise locally
|
||||||
|
const databasePromise = response.databasePromise;
|
||||||
|
delete response.databasePromise;
|
||||||
|
|
||||||
|
// Resolve database-related data
|
||||||
|
const { conversation: convoData = {} } = await databasePromise;
|
||||||
|
const conversation = { ...convoData };
|
||||||
conversation.title =
|
conversation.title =
|
||||||
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
|
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
|
||||||
|
|
||||||
if (req.body.files && client.options.attachments) {
|
// Process files if needed
|
||||||
|
if (req.body.files && client.options?.attachments) {
|
||||||
userMessage.files = [];
|
userMessage.files = [];
|
||||||
const messageFiles = new Set(req.body.files.map((file) => file.file_id));
|
const messageFiles = new Set(req.body.files.map((file) => file.file_id));
|
||||||
for (let attachment of client.options.attachments) {
|
for (let attachment of client.options.attachments) {
|
||||||
if (messageFiles.has(attachment.file_id)) {
|
if (messageFiles.has(attachment.file_id)) {
|
||||||
userMessage.files.push(attachment);
|
userMessage.files.push({ ...attachment });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
delete userMessage.image_urls;
|
delete userMessage.image_urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only send if not aborted
|
||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) {
|
||||||
|
// Create a new response object with minimal copies
|
||||||
|
const finalResponse = { ...response };
|
||||||
|
|
||||||
sendMessage(res, {
|
sendMessage(res, {
|
||||||
final: true,
|
final: true,
|
||||||
conversation,
|
conversation,
|
||||||
title: conversation.title,
|
title: conversation.title,
|
||||||
requestMessage: userMessage,
|
requestMessage: userMessage,
|
||||||
responseMessage: response,
|
responseMessage: finalResponse,
|
||||||
});
|
});
|
||||||
res.end();
|
res.end();
|
||||||
|
|
||||||
if (!client.savedMessageIds.has(response.messageId)) {
|
// Save the message if needed
|
||||||
|
if (client.savedMessageIds && !client.savedMessageIds.has(messageId)) {
|
||||||
await saveMessage(
|
await saveMessage(
|
||||||
req,
|
req,
|
||||||
{ ...response, user },
|
{ ...finalResponse, user: userId },
|
||||||
{ context: 'api/server/controllers/agents/request.js - response end' },
|
{ context: 'api/server/controllers/agents/request.js - response end' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save user message if needed
|
||||||
if (!client.skipSaveUserMessage) {
|
if (!client.skipSaveUserMessage) {
|
||||||
await saveMessage(req, userMessage, {
|
await saveMessage(req, userMessage, {
|
||||||
context: 'api/server/controllers/agents/request.js - don\'t skip saving user message',
|
context: 'api/server/controllers/agents/request.js - don\'t skip saving user message',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add title if needed - extract minimal data
|
||||||
if (addTitle && parentMessageId === Constants.NO_PARENT && newConvo) {
|
if (addTitle && parentMessageId === Constants.NO_PARENT && newConvo) {
|
||||||
addTitle(req, {
|
addTitle(req, {
|
||||||
text,
|
text,
|
||||||
response,
|
response: { ...response },
|
||||||
client,
|
client,
|
||||||
});
|
})
|
||||||
|
.then(() => {
|
||||||
|
logger.debug('[AgentController] Title generation started');
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.error('[AgentController] Error in title generation', err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
logger.debug('[AgentController] Title generation completed');
|
||||||
|
performCleanup();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
performCleanup();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Handle error without capturing much scope
|
||||||
handleAbortError(res, req, error, {
|
handleAbortError(res, req, error, {
|
||||||
conversationId,
|
conversationId,
|
||||||
sender,
|
sender,
|
||||||
messageId: responseMessageId,
|
messageId: responseMessageId,
|
||||||
parentMessageId: overrideParentMessageId ?? userMessageId ?? parentMessageId,
|
parentMessageId: overrideParentMessageId ?? userMessageId ?? parentMessageId,
|
||||||
}).catch((err) => {
|
userMessageId,
|
||||||
logger.error('[api/server/controllers/agents/request] Error in `handleAbortError`', err);
|
})
|
||||||
});
|
.catch((err) => {
|
||||||
|
logger.error('[api/server/controllers/agents/request] Error in `handleAbortError`', err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
performCleanup();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ const { providerEndpointMap, KnownEndpoints } = require('librechat-data-provider
|
|||||||
* @typedef {import('@librechat/agents').IState} IState
|
* @typedef {import('@librechat/agents').IState} IState
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const customProviders = new Set([
|
||||||
|
Providers.XAI,
|
||||||
|
Providers.OLLAMA,
|
||||||
|
Providers.DEEPSEEK,
|
||||||
|
Providers.OPENROUTER,
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new Run instance with custom handlers and configuration.
|
* Creates a new Run instance with custom handlers and configuration.
|
||||||
*
|
*
|
||||||
@@ -43,8 +50,11 @@ async function createRun({
|
|||||||
agent.model_parameters,
|
agent.model_parameters,
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Resolves Mistral type strictness due to new OpenAI usage field */
|
/** Resolves issues with new OpenAI usage field */
|
||||||
if (agent.endpoint?.toLowerCase().includes(KnownEndpoints.mistral)) {
|
if (
|
||||||
|
customProviders.has(agent.provider) ||
|
||||||
|
(agent.provider === Providers.OPENAI && agent.endpoint !== agent.provider)
|
||||||
|
) {
|
||||||
llmConfig.streamUsage = false;
|
llmConfig.streamUsage = false;
|
||||||
llmConfig.usage = true;
|
llmConfig.usage = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const { updateAction, getActions } = require('~/models/Action');
|
|||||||
const { updateAgentProjects } = require('~/models/Agent');
|
const { updateAgentProjects } = require('~/models/Agent');
|
||||||
const { getProjectByName } = require('~/models/Project');
|
const { getProjectByName } = require('~/models/Project');
|
||||||
const { deleteFileByFilter } = require('~/models/File');
|
const { deleteFileByFilter } = require('~/models/File');
|
||||||
|
const { revertAgentVersion } = require('~/models/Agent');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
const systemTools = {
|
const systemTools = {
|
||||||
@@ -104,6 +105,8 @@ const getAgentHandler = async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Agent not found' });
|
return res.status(404).json({ error: 'Agent not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
agent.version = agent.versions ? agent.versions.length : 0;
|
||||||
|
|
||||||
if (agent.avatar && agent.avatar?.source === FileSources.s3) {
|
if (agent.avatar && agent.avatar?.source === FileSources.s3) {
|
||||||
const originalUrl = agent.avatar.filepath;
|
const originalUrl = agent.avatar.filepath;
|
||||||
agent.avatar.filepath = await refreshS3Url(agent.avatar);
|
agent.avatar.filepath = await refreshS3Url(agent.avatar);
|
||||||
@@ -127,6 +130,7 @@ const getAgentHandler = async (req, res) => {
|
|||||||
author: agent.author,
|
author: agent.author,
|
||||||
projectIds: agent.projectIds,
|
projectIds: agent.projectIds,
|
||||||
isCollaborative: agent.isCollaborative,
|
isCollaborative: agent.isCollaborative,
|
||||||
|
version: agent.version,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return res.status(200).json(agent);
|
return res.status(200).json(agent);
|
||||||
@@ -187,6 +191,14 @@ const updateAgentHandler = async (req, res) => {
|
|||||||
return res.json(updatedAgent);
|
return res.json(updatedAgent);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[/Agents/:id] Error updating Agent', error);
|
logger.error('[/Agents/:id] Error updating Agent', error);
|
||||||
|
|
||||||
|
if (error.statusCode === 409) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: error.message,
|
||||||
|
details: error.details,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -411,6 +423,66 @@ const uploadAgentAvatarHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverts an agent to a previous version from its version history.
|
||||||
|
* @route PATCH /agents/:id/revert
|
||||||
|
* @param {object} req - Express Request object
|
||||||
|
* @param {object} req.params - Request parameters
|
||||||
|
* @param {string} req.params.id - The ID of the agent to revert
|
||||||
|
* @param {object} req.body - Request body
|
||||||
|
* @param {number} req.body.version_index - The index of the version to revert to
|
||||||
|
* @param {object} req.user - Authenticated user information
|
||||||
|
* @param {string} req.user.id - User ID
|
||||||
|
* @param {string} req.user.role - User role
|
||||||
|
* @param {ServerResponse} res - Express Response object
|
||||||
|
* @returns {Promise<Agent>} 200 - The updated agent after reverting to the specified version
|
||||||
|
* @throws {Error} 400 - If version_index is missing
|
||||||
|
* @throws {Error} 403 - If user doesn't have permission to modify the agent
|
||||||
|
* @throws {Error} 404 - If agent not found
|
||||||
|
* @throws {Error} 500 - If there's an internal server error during the reversion process
|
||||||
|
*/
|
||||||
|
const revertAgentVersionHandler = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { version_index } = req.body;
|
||||||
|
|
||||||
|
if (version_index === undefined) {
|
||||||
|
return res.status(400).json({ error: 'version_index is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = req.user.role === SystemRoles.ADMIN;
|
||||||
|
const existingAgent = await getAgent({ id });
|
||||||
|
|
||||||
|
if (!existingAgent) {
|
||||||
|
return res.status(404).json({ error: 'Agent not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthor = existingAgent.author.toString() === req.user.id;
|
||||||
|
const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor;
|
||||||
|
|
||||||
|
if (!hasEditPermission) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'You do not have permission to modify this non-collaborative agent',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAgent = await revertAgentVersion({ id }, version_index);
|
||||||
|
|
||||||
|
if (updatedAgent.author) {
|
||||||
|
updatedAgent.author = updatedAgent.author.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedAgent.author !== req.user.id) {
|
||||||
|
delete updatedAgent.author;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(updatedAgent);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[/agents/:id/revert] Error reverting Agent version', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createAgent: createAgentHandler,
|
createAgent: createAgentHandler,
|
||||||
getAgent: getAgentHandler,
|
getAgent: getAgentHandler,
|
||||||
@@ -419,4 +491,5 @@ module.exports = {
|
|||||||
deleteAgent: deleteAgentHandler,
|
deleteAgent: deleteAgentHandler,
|
||||||
getListAgents: getListAgentsHandler,
|
getListAgents: getListAgentsHandler,
|
||||||
uploadAgentAvatar: uploadAgentAvatarHandler,
|
uploadAgentAvatar: uploadAgentAvatarHandler,
|
||||||
|
revertAgentVersion: revertAgentVersionHandler,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ const chatV1 = async (req, res) => {
|
|||||||
} else if (/Files.*are invalid/.test(error.message)) {
|
} else if (/Files.*are invalid/.test(error.message)) {
|
||||||
const errorMessage = `Files are invalid, or may not have uploaded yet.${
|
const errorMessage = `Files are invalid, or may not have uploaded yet.${
|
||||||
endpoint === EModelEndpoint.azureAssistants
|
endpoint === EModelEndpoint.azureAssistants
|
||||||
? ' If using Azure OpenAI, files are only available in the region of the assistant\'s model at the time of upload.'
|
? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
|
||||||
: ''
|
: ''
|
||||||
}`;
|
}`;
|
||||||
return sendResponse(req, res, messageData, errorMessage);
|
return sendResponse(req, res, messageData, errorMessage);
|
||||||
@@ -326,8 +326,15 @@ const chatV1 = async (req, res) => {
|
|||||||
|
|
||||||
file_ids = files.map(({ file_id }) => file_id);
|
file_ids = files.map(({ file_id }) => file_id);
|
||||||
if (file_ids.length || thread_file_ids.length) {
|
if (file_ids.length || thread_file_ids.length) {
|
||||||
userMessage.file_ids = file_ids;
|
|
||||||
attachedFileIds = new Set([...file_ids, ...thread_file_ids]);
|
attachedFileIds = new Set([...file_ids, ...thread_file_ids]);
|
||||||
|
if (endpoint === EModelEndpoint.azureAssistants) {
|
||||||
|
userMessage.attachments = Array.from(attachedFileIds).map((file_id) => ({
|
||||||
|
file_id,
|
||||||
|
tools: [{ type: 'file_search' }],
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
userMessage.file_ids = Array.from(attachedFileIds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -379,8 +386,8 @@ const chatV1 = async (req, res) => {
|
|||||||
body.additional_instructions ? `${body.additional_instructions}\n` : ''
|
body.additional_instructions ? `${body.additional_instructions}\n` : ''
|
||||||
}The user has uploaded ${imageCount} image${pluralized}.
|
}The user has uploaded ${imageCount} image${pluralized}.
|
||||||
Use the \`${ImageVisionTool.function.name}\` tool to retrieve ${
|
Use the \`${ImageVisionTool.function.name}\` tool to retrieve ${
|
||||||
plural ? '' : 'a '
|
plural ? '' : 'a '
|
||||||
}detailed text description${pluralized} for ${plural ? 'each' : 'the'} image${pluralized}.`;
|
}detailed text description${pluralized} for ${plural ? 'each' : 'the'} image${pluralized}.`;
|
||||||
|
|
||||||
return files;
|
return files;
|
||||||
};
|
};
|
||||||
@@ -576,6 +583,8 @@ const chatV1 = async (req, res) => {
|
|||||||
thread_id,
|
thread_id,
|
||||||
model: assistant_id,
|
model: assistant_id,
|
||||||
endpoint,
|
endpoint,
|
||||||
|
spec: endpointOption.spec,
|
||||||
|
iconURL: endpointOption.iconURL,
|
||||||
};
|
};
|
||||||
|
|
||||||
sendMessage(res, {
|
sendMessage(res, {
|
||||||
|
|||||||
@@ -428,6 +428,8 @@ const chatV2 = async (req, res) => {
|
|||||||
thread_id,
|
thread_id,
|
||||||
model: assistant_id,
|
model: assistant_id,
|
||||||
endpoint,
|
endpoint,
|
||||||
|
spec: endpointOption.spec,
|
||||||
|
iconURL: endpointOption.iconURL,
|
||||||
};
|
};
|
||||||
|
|
||||||
sendMessage(res, {
|
sendMessage(res, {
|
||||||
|
|||||||
@@ -24,10 +24,13 @@ const routes = require('./routes');
|
|||||||
|
|
||||||
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
|
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
|
||||||
|
|
||||||
const port = Number(PORT) || 3080;
|
// Allow PORT=0 to be used for automatic free port assignment
|
||||||
|
const port = isNaN(Number(PORT)) ? 3080 : Number(PORT);
|
||||||
const host = HOST || 'localhost';
|
const host = HOST || 'localhost';
|
||||||
const trusted_proxy = Number(TRUST_PROXY) || 1; /* trust first proxy by default */
|
const trusted_proxy = Number(TRUST_PROXY) || 1; /* trust first proxy by default */
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
const startServer = async () => {
|
const startServer = async () => {
|
||||||
if (typeof Bun !== 'undefined') {
|
if (typeof Bun !== 'undefined') {
|
||||||
axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
|
axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
|
||||||
@@ -36,8 +39,9 @@ const startServer = async () => {
|
|||||||
logger.info('Connected to MongoDB');
|
logger.info('Connected to MongoDB');
|
||||||
await indexSync();
|
await indexSync();
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
|
app.set('trust proxy', trusted_proxy);
|
||||||
|
|
||||||
await AppService(app);
|
await AppService(app);
|
||||||
|
|
||||||
const indexPath = path.join(app.locals.paths.dist, 'index.html');
|
const indexPath = path.join(app.locals.paths.dist, 'index.html');
|
||||||
@@ -49,23 +53,24 @@ const startServer = async () => {
|
|||||||
app.use(noIndex);
|
app.use(noIndex);
|
||||||
app.use(errorController);
|
app.use(errorController);
|
||||||
app.use(express.json({ limit: '3mb' }));
|
app.use(express.json({ limit: '3mb' }));
|
||||||
app.use(mongoSanitize());
|
|
||||||
app.use(express.urlencoded({ extended: true, limit: '3mb' }));
|
app.use(express.urlencoded({ extended: true, limit: '3mb' }));
|
||||||
app.use(staticCache(app.locals.paths.dist));
|
app.use(mongoSanitize());
|
||||||
app.use(staticCache(app.locals.paths.fonts));
|
|
||||||
app.use(staticCache(app.locals.paths.assets));
|
|
||||||
app.set('trust proxy', trusted_proxy);
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
if (!isEnabled(DISABLE_COMPRESSION)) {
|
if (!isEnabled(DISABLE_COMPRESSION)) {
|
||||||
app.use(compression());
|
app.use(compression());
|
||||||
|
} else {
|
||||||
|
console.warn('Response compression has been disabled via DISABLE_COMPRESSION.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Serve static assets with aggressive caching
|
||||||
|
app.use(staticCache(app.locals.paths.dist));
|
||||||
|
app.use(staticCache(app.locals.paths.fonts));
|
||||||
|
app.use(staticCache(app.locals.paths.assets));
|
||||||
|
|
||||||
if (!ALLOW_SOCIAL_LOGIN) {
|
if (!ALLOW_SOCIAL_LOGIN) {
|
||||||
console.warn(
|
console.warn('Social logins are disabled. Set ALLOW_SOCIAL_LOGIN=true to enable them.');
|
||||||
'Social logins are disabled. Set Environment Variable "ALLOW_SOCIAL_LOGIN" to true to enable them.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* OAUTH */
|
/* OAUTH */
|
||||||
@@ -88,8 +93,8 @@ const startServer = async () => {
|
|||||||
app.use('/api/actions', routes.actions);
|
app.use('/api/actions', routes.actions);
|
||||||
app.use('/api/keys', routes.keys);
|
app.use('/api/keys', routes.keys);
|
||||||
app.use('/api/user', routes.user);
|
app.use('/api/user', routes.user);
|
||||||
app.use('/api/search', routes.search);
|
|
||||||
app.use('/api/ask', routes.ask);
|
app.use('/api/ask', routes.ask);
|
||||||
|
app.use('/api/search', routes.search);
|
||||||
app.use('/api/edit', routes.edit);
|
app.use('/api/edit', routes.edit);
|
||||||
app.use('/api/messages', routes.messages);
|
app.use('/api/messages', routes.messages);
|
||||||
app.use('/api/convos', routes.convos);
|
app.use('/api/convos', routes.convos);
|
||||||
@@ -128,7 +133,7 @@ const startServer = async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.listen(port, host, () => {
|
app.listen(port, host, () => {
|
||||||
if (host == '0.0.0.0') {
|
if (host === '0.0.0.0') {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Server listening on all interfaces at port ${port}. Use http://localhost:${port} to access it`,
|
`Server listening on all interfaces at port ${port}. Use http://localhost:${port} to access it`,
|
||||||
);
|
);
|
||||||
@@ -176,3 +181,6 @@ process.on('uncaughtException', (err) => {
|
|||||||
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// export app for easier testing purposes
|
||||||
|
module.exports = app;
|
||||||
|
|||||||
78
api/server/index.spec.js
Normal file
78
api/server/index.spec.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const request = require('supertest');
|
||||||
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
describe('Server Configuration', () => {
|
||||||
|
// Increase the default timeout to allow for Mongo cleanup
|
||||||
|
jest.setTimeout(30_000);
|
||||||
|
|
||||||
|
let mongoServer;
|
||||||
|
let app;
|
||||||
|
|
||||||
|
/** Mocked fs.readFileSync for index.html */
|
||||||
|
const originalReadFileSync = fs.readFileSync;
|
||||||
|
beforeAll(() => {
|
||||||
|
fs.readFileSync = function (filepath, options) {
|
||||||
|
if (filepath.includes('index.html')) {
|
||||||
|
return '<!DOCTYPE html><html><head><title>LibreChat</title></head><body><div id="root"></div></body></html>';
|
||||||
|
}
|
||||||
|
return originalReadFileSync(filepath, options);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
// Restore original fs.readFileSync
|
||||||
|
fs.readFileSync = originalReadFileSync;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mongoServer = await MongoMemoryServer.create();
|
||||||
|
process.env.MONGO_URI = mongoServer.getUri();
|
||||||
|
process.env.PORT = '0'; // Use a random available port
|
||||||
|
app = require('~/server');
|
||||||
|
|
||||||
|
// Wait for the app to be healthy
|
||||||
|
await healthCheckPoll(app);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await mongoServer.stop();
|
||||||
|
await mongoose.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return OK for /health', async () => {
|
||||||
|
const response = await request(app).get('/health');
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.text).toBe('OK');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not cache index page', async () => {
|
||||||
|
const response = await request(app).get('/');
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
|
||||||
|
expect(response.headers['pragma']).toBe('no-cache');
|
||||||
|
expect(response.headers['expires']).toBe('0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Polls the /health endpoint every 30ms for up to 10 seconds to wait for the server to start completely
|
||||||
|
async function healthCheckPoll(app, retries = 0) {
|
||||||
|
const maxRetries = Math.floor(10000 / 30); // 10 seconds / 30ms
|
||||||
|
try {
|
||||||
|
const response = await request(app).get('/health');
|
||||||
|
if (response.status === 200) {
|
||||||
|
return; // App is healthy
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore connection errors during polling
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retries < maxRetries) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
await healthCheckPoll(app, retries + 1);
|
||||||
|
} else {
|
||||||
|
throw new Error('App did not become healthy within 10 seconds.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// abortMiddleware.js
|
||||||
const { isAssistantsEndpoint, ErrorTypes } = require('librechat-data-provider');
|
const { isAssistantsEndpoint, ErrorTypes } = require('librechat-data-provider');
|
||||||
const { sendMessage, sendError, countTokens, isEnabled } = require('~/server/utils');
|
const { sendMessage, sendError, countTokens, isEnabled } = require('~/server/utils');
|
||||||
const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
|
const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
|
||||||
@@ -8,6 +9,68 @@ const { saveMessage, getConvo } = require('~/models');
|
|||||||
const { abortRun } = require('./abortRun');
|
const { abortRun } = require('./abortRun');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
|
const abortDataMap = new WeakMap();
|
||||||
|
|
||||||
|
function cleanupAbortController(abortKey) {
|
||||||
|
if (!abortControllers.has(abortKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { abortController } = abortControllers.get(abortKey);
|
||||||
|
|
||||||
|
if (!abortController) {
|
||||||
|
abortControllers.delete(abortKey);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Check if this controller has any composed signals and clean them up
|
||||||
|
try {
|
||||||
|
// This creates a temporary composed signal to use for cleanup
|
||||||
|
const composedSignal = AbortSignal.any([abortController.signal]);
|
||||||
|
|
||||||
|
// Get all event types - in practice, AbortSignal typically only uses 'abort'
|
||||||
|
const eventTypes = ['abort'];
|
||||||
|
|
||||||
|
// First, execute a dummy listener removal to handle potential composed signals
|
||||||
|
for (const eventType of eventTypes) {
|
||||||
|
const dummyHandler = () => {};
|
||||||
|
composedSignal.addEventListener(eventType, dummyHandler);
|
||||||
|
composedSignal.removeEventListener(eventType, dummyHandler);
|
||||||
|
|
||||||
|
const listeners = composedSignal.listeners?.(eventType) || [];
|
||||||
|
for (const listener of listeners) {
|
||||||
|
composedSignal.removeEventListener(eventType, listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug(`Error cleaning up composed signals: ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Abort the controller if not already aborted
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
abortController.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Remove from registry
|
||||||
|
abortControllers.delete(abortKey);
|
||||||
|
|
||||||
|
// 4. Clean up any data stored in the WeakMap
|
||||||
|
if (abortDataMap.has(abortController)) {
|
||||||
|
abortDataMap.delete(abortController);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Clean up function references on the controller
|
||||||
|
if (abortController.getAbortData) {
|
||||||
|
abortController.getAbortData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abortController.abortCompletion) {
|
||||||
|
abortController.abortCompletion = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async function abortMessage(req, res) {
|
async function abortMessage(req, res) {
|
||||||
let { abortKey, endpoint } = req.body;
|
let { abortKey, endpoint } = req.body;
|
||||||
|
|
||||||
@@ -29,24 +92,24 @@ async function abortMessage(req, res) {
|
|||||||
if (!abortController) {
|
if (!abortController) {
|
||||||
return res.status(204).send({ message: 'Request not found' });
|
return res.status(204).send({ message: 'Request not found' });
|
||||||
}
|
}
|
||||||
const finalEvent = await abortController.abortCompletion();
|
|
||||||
|
const finalEvent = await abortController.abortCompletion?.();
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[abortMessage] ID: ${req.user.id} | ${req.user.email} | Aborted request: ` +
|
`[abortMessage] ID: ${req.user.id} | ${req.user.email} | Aborted request: ` +
|
||||||
JSON.stringify({ abortKey }),
|
JSON.stringify({ abortKey }),
|
||||||
);
|
);
|
||||||
abortControllers.delete(abortKey);
|
cleanupAbortController(abortKey);
|
||||||
|
|
||||||
if (res.headersSent && finalEvent) {
|
if (res.headersSent && finalEvent) {
|
||||||
return sendMessage(res, finalEvent);
|
return sendMessage(res, finalEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/json');
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
|
||||||
res.send(JSON.stringify(finalEvent));
|
res.send(JSON.stringify(finalEvent));
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAbort = () => {
|
const handleAbort = function () {
|
||||||
return async (req, res) => {
|
return async function (req, res) {
|
||||||
try {
|
try {
|
||||||
if (isEnabled(process.env.LIMIT_CONCURRENT_MESSAGES)) {
|
if (isEnabled(process.env.LIMIT_CONCURRENT_MESSAGES)) {
|
||||||
await clearPendingReq({ userId: req.user.id });
|
await clearPendingReq({ userId: req.user.id });
|
||||||
@@ -62,8 +125,48 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
|
|||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const { endpointOption } = req.body;
|
const { endpointOption } = req.body;
|
||||||
|
|
||||||
|
// Store minimal data in WeakMap to avoid circular references
|
||||||
|
abortDataMap.set(abortController, {
|
||||||
|
getAbortDataFn: getAbortData,
|
||||||
|
userId: req.user.id,
|
||||||
|
endpoint: endpointOption.endpoint,
|
||||||
|
iconURL: endpointOption.iconURL,
|
||||||
|
model: endpointOption.modelOptions?.model || endpointOption.model_parameters?.model,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replace the direct function reference with a wrapper that uses WeakMap
|
||||||
abortController.getAbortData = function () {
|
abortController.getAbortData = function () {
|
||||||
return getAbortData();
|
const data = abortDataMap.get(this);
|
||||||
|
if (!data || typeof data.getAbortDataFn !== 'function') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = data.getAbortDataFn();
|
||||||
|
|
||||||
|
// Create a copy without circular references
|
||||||
|
const cleanResult = { ...result };
|
||||||
|
|
||||||
|
// If userMessagePromise exists, break its reference to client
|
||||||
|
if (
|
||||||
|
cleanResult.userMessagePromise &&
|
||||||
|
typeof cleanResult.userMessagePromise.then === 'function'
|
||||||
|
) {
|
||||||
|
// Create a new promise that fulfills with the same result but doesn't reference the original
|
||||||
|
const originalPromise = cleanResult.userMessagePromise;
|
||||||
|
cleanResult.userMessagePromise = new Promise((resolve, reject) => {
|
||||||
|
originalPromise.then(
|
||||||
|
(result) => resolve({ ...result }),
|
||||||
|
(error) => reject(error),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanResult;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[abortController.getAbortData] Error:', err);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,6 +177,7 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
|
|||||||
sendMessage(res, { message: userMessage, created: true });
|
sendMessage(res, { message: userMessage, created: true });
|
||||||
|
|
||||||
const abortKey = userMessage?.conversationId ?? req.user.id;
|
const abortKey = userMessage?.conversationId ?? req.user.id;
|
||||||
|
getReqData({ abortKey });
|
||||||
const prevRequest = abortControllers.get(abortKey);
|
const prevRequest = abortControllers.get(abortKey);
|
||||||
const { overrideUserMessageId } = req?.body ?? {};
|
const { overrideUserMessageId } = req?.body ?? {};
|
||||||
|
|
||||||
@@ -81,34 +185,74 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
|
|||||||
const data = prevRequest.abortController.getAbortData();
|
const data = prevRequest.abortController.getAbortData();
|
||||||
getReqData({ userMessage: data?.userMessage });
|
getReqData({ userMessage: data?.userMessage });
|
||||||
const addedAbortKey = `${abortKey}:${responseMessageId}`;
|
const addedAbortKey = `${abortKey}:${responseMessageId}`;
|
||||||
abortControllers.set(addedAbortKey, { abortController, ...endpointOption });
|
|
||||||
res.on('finish', function () {
|
// Store minimal options
|
||||||
abortControllers.delete(addedAbortKey);
|
const minimalOptions = {
|
||||||
});
|
endpoint: endpointOption.endpoint,
|
||||||
|
iconURL: endpointOption.iconURL,
|
||||||
|
model: endpointOption.modelOptions?.model || endpointOption.model_parameters?.model,
|
||||||
|
};
|
||||||
|
|
||||||
|
abortControllers.set(addedAbortKey, { abortController, ...minimalOptions });
|
||||||
|
|
||||||
|
// Use a simple function for cleanup to avoid capturing context
|
||||||
|
const cleanupHandler = () => {
|
||||||
|
try {
|
||||||
|
cleanupAbortController(addedAbortKey);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
res.on('finish', cleanupHandler);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
abortControllers.set(abortKey, { abortController, ...endpointOption });
|
// Store minimal options
|
||||||
|
const minimalOptions = {
|
||||||
|
endpoint: endpointOption.endpoint,
|
||||||
|
iconURL: endpointOption.iconURL,
|
||||||
|
model: endpointOption.modelOptions?.model || endpointOption.model_parameters?.model,
|
||||||
|
};
|
||||||
|
|
||||||
res.on('finish', function () {
|
abortControllers.set(abortKey, { abortController, ...minimalOptions });
|
||||||
abortControllers.delete(abortKey);
|
|
||||||
});
|
// Use a simple function for cleanup to avoid capturing context
|
||||||
|
const cleanupHandler = () => {
|
||||||
|
try {
|
||||||
|
cleanupAbortController(abortKey);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
res.on('finish', cleanupHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Define abortCompletion without capturing the entire parent scope
|
||||||
abortController.abortCompletion = async function () {
|
abortController.abortCompletion = async function () {
|
||||||
abortController.abort();
|
this.abort();
|
||||||
|
|
||||||
|
// Get data from WeakMap
|
||||||
|
const ctrlData = abortDataMap.get(this);
|
||||||
|
if (!ctrlData || !ctrlData.getAbortDataFn) {
|
||||||
|
return { final: true, conversation: {}, title: 'New Chat' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get abort data using stored function
|
||||||
const { conversationId, userMessage, userMessagePromise, promptTokens, ...responseData } =
|
const { conversationId, userMessage, userMessagePromise, promptTokens, ...responseData } =
|
||||||
getAbortData();
|
ctrlData.getAbortDataFn();
|
||||||
|
|
||||||
const completionTokens = await countTokens(responseData?.text ?? '');
|
const completionTokens = await countTokens(responseData?.text ?? '');
|
||||||
const user = req.user.id;
|
const user = ctrlData.userId;
|
||||||
|
|
||||||
const responseMessage = {
|
const responseMessage = {
|
||||||
...responseData,
|
...responseData,
|
||||||
conversationId,
|
conversationId,
|
||||||
finish_reason: 'incomplete',
|
finish_reason: 'incomplete',
|
||||||
endpoint: endpointOption.endpoint,
|
endpoint: ctrlData.endpoint,
|
||||||
iconURL: endpointOption.iconURL,
|
iconURL: ctrlData.iconURL,
|
||||||
model: endpointOption.modelOptions?.model ?? endpointOption.model_parameters?.model,
|
model: ctrlData.modelOptions?.model ?? ctrlData.model_parameters?.model,
|
||||||
unfinished: false,
|
unfinished: false,
|
||||||
error: false,
|
error: false,
|
||||||
isCreatedByUser: false,
|
isCreatedByUser: false,
|
||||||
@@ -130,10 +274,12 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
|
|||||||
if (userMessagePromise) {
|
if (userMessagePromise) {
|
||||||
const resolved = await userMessagePromise;
|
const resolved = await userMessagePromise;
|
||||||
conversation = resolved?.conversation;
|
conversation = resolved?.conversation;
|
||||||
|
// Break reference to promise
|
||||||
|
resolved.conversation = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
conversation = await getConvo(req.user.id, conversationId);
|
conversation = await getConvo(user, conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -165,7 +311,7 @@ const handleAbortError = async (res, req, error, data) => {
|
|||||||
} else {
|
} else {
|
||||||
logger.error('[handleAbortError] AI response error; aborting request:', error);
|
logger.error('[handleAbortError] AI response error; aborting request:', error);
|
||||||
}
|
}
|
||||||
const { sender, conversationId, messageId, parentMessageId, partialText } = data;
|
const { sender, conversationId, messageId, parentMessageId, userMessageId, partialText } = data;
|
||||||
|
|
||||||
if (error.stack && error.stack.includes('google')) {
|
if (error.stack && error.stack.includes('google')) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -198,10 +344,10 @@ const handleAbortError = async (res, req, error, data) => {
|
|||||||
parentMessageId,
|
parentMessageId,
|
||||||
text: errorText,
|
text: errorText,
|
||||||
user: req.user.id,
|
user: req.user.id,
|
||||||
shouldSaveMessage: true,
|
|
||||||
spec: endpointOption?.spec,
|
spec: endpointOption?.spec,
|
||||||
iconURL: endpointOption?.iconURL,
|
iconURL: endpointOption?.iconURL,
|
||||||
modelLabel: endpointOption?.modelLabel,
|
modelLabel: endpointOption?.modelLabel,
|
||||||
|
shouldSaveMessage: userMessageId != null,
|
||||||
model: endpointOption?.modelOptions?.model || req.body?.model,
|
model: endpointOption?.modelOptions?.model || req.body?.model,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -218,11 +364,12 @@ const handleAbortError = async (res, req, error, data) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a simple callback without capturing parent scope
|
||||||
const callback = async () => {
|
const callback = async () => {
|
||||||
if (abortControllers.has(conversationId)) {
|
try {
|
||||||
const { abortController } = abortControllers.get(conversationId);
|
cleanupAbortController(conversationId);
|
||||||
abortController.abort();
|
} catch (e) {
|
||||||
abortControllers.delete(conversationId);
|
// Ignore cleanup errors
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -243,6 +390,7 @@ const handleAbortError = async (res, req, error, data) => {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
handleAbort,
|
handleAbort,
|
||||||
createAbortController,
|
|
||||||
handleAbortError,
|
handleAbortError,
|
||||||
|
createAbortController,
|
||||||
|
cleanupAbortController,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
const { parseCompactConvo, EModelEndpoint, isAgentsEndpoint } = require('librechat-data-provider');
|
const {
|
||||||
const { getModelsConfig } = require('~/server/controllers/ModelController');
|
parseCompactConvo,
|
||||||
|
EModelEndpoint,
|
||||||
|
isAgentsEndpoint,
|
||||||
|
EndpointURLs,
|
||||||
|
} = require('librechat-data-provider');
|
||||||
const azureAssistants = require('~/server/services/Endpoints/azureAssistants');
|
const azureAssistants = require('~/server/services/Endpoints/azureAssistants');
|
||||||
|
const { getModelsConfig } = require('~/server/controllers/ModelController');
|
||||||
const assistants = require('~/server/services/Endpoints/assistants');
|
const assistants = require('~/server/services/Endpoints/assistants');
|
||||||
const gptPlugins = require('~/server/services/Endpoints/gptPlugins');
|
const gptPlugins = require('~/server/services/Endpoints/gptPlugins');
|
||||||
const { processFiles } = require('~/server/services/Files/process');
|
const { processFiles } = require('~/server/services/Files/process');
|
||||||
@@ -77,8 +82,9 @@ async function buildEndpointOption(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isAgents = isAgentsEndpoint(endpoint);
|
const isAgents =
|
||||||
const endpointFn = buildFunction[endpointType ?? endpoint];
|
isAgentsEndpoint(endpoint) || req.baseUrl.startsWith(EndpointURLs[EModelEndpoint.agents]);
|
||||||
|
const endpointFn = buildFunction[isAgents ? EModelEndpoint.agents : (endpointType ?? endpoint)];
|
||||||
const builder = isAgents ? (...args) => endpointFn(req, ...args) : endpointFn;
|
const builder = isAgents ? (...args) => endpointFn(req, ...args) : endpointFn;
|
||||||
|
|
||||||
// TODO: use object params
|
// TODO: use object params
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const Keyv = require('keyv');
|
const { Keyv } = require('keyv');
|
||||||
const uap = require('ua-parser-js');
|
const uap = require('ua-parser-js');
|
||||||
const { ViolationTypes } = require('librechat-data-provider');
|
const { ViolationTypes } = require('librechat-data-provider');
|
||||||
const { isEnabled, removePorts } = require('~/server/utils');
|
const { isEnabled, removePorts } = require('~/server/utils');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { Time } = require('librechat-data-provider');
|
const { Time, CacheKeys } = 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');
|
||||||
@@ -25,7 +25,7 @@ const {
|
|||||||
* @throws {Error} Throws an error if the user exceeds the concurrent request limit.
|
* @throws {Error} Throws an error if the user exceeds the concurrent request limit.
|
||||||
*/
|
*/
|
||||||
const concurrentLimiter = async (req, res, next) => {
|
const concurrentLimiter = async (req, res, next) => {
|
||||||
const namespace = 'pending_req';
|
const namespace = CacheKeys.PENDING_REQ;
|
||||||
const cache = getLogStores(namespace);
|
const cache = getLogStores(namespace);
|
||||||
if (!cache) {
|
if (!cache) {
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
const Keyv = require('keyv');
|
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
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 { isEnabled } = require('~/server/utils');
|
||||||
const keyvRedis = require('~/cache/keyvRedis');
|
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
const getEnvironmentVariables = () => {
|
const getEnvironmentVariables = () => {
|
||||||
@@ -67,11 +66,9 @@ const createImportLimiters = () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEnabled(process.env.USE_REDIS)) {
|
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
||||||
logger.debug('Using Redis for import rate limiters.');
|
logger.debug('Using Redis for import rate limiters.');
|
||||||
const keyv = new Keyv({ store: keyvRedis });
|
const sendCommand = (...args) => ioredisClient.call(...args);
|
||||||
const client = keyv.opts.store.redis;
|
|
||||||
const sendCommand = (...args) => client.call(...args);
|
|
||||||
const ipStore = new RedisStore({
|
const ipStore = new RedisStore({
|
||||||
sendCommand,
|
sendCommand,
|
||||||
prefix: 'import_ip_limiter:',
|
prefix: 'import_ip_limiter:',
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
const Keyv = require('keyv');
|
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
const { RedisStore } = require('rate-limit-redis');
|
||||||
const { removePorts, isEnabled } = require('~/server/utils');
|
const { removePorts, isEnabled } = require('~/server/utils');
|
||||||
const keyvRedis = require('~/cache/keyvRedis');
|
const ioredisClient = require('~/cache/ioredisClient');
|
||||||
const { logViolation } = require('~/cache');
|
const { logViolation } = require('~/cache');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
@@ -31,13 +30,10 @@ const limiterOptions = {
|
|||||||
keyGenerator: removePorts,
|
keyGenerator: removePorts,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEnabled(process.env.USE_REDIS)) {
|
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
||||||
logger.debug('Using Redis for login rate limiter.');
|
logger.debug('Using Redis for login rate limiter.');
|
||||||
const keyv = new Keyv({ store: keyvRedis });
|
|
||||||
const client = keyv.opts.store.redis;
|
|
||||||
const sendCommand = (...args) => client.call(...args);
|
|
||||||
const store = new RedisStore({
|
const store = new RedisStore({
|
||||||
sendCommand,
|
sendCommand: (...args) => ioredisClient.call(...args),
|
||||||
prefix: 'login_limiter:',
|
prefix: 'login_limiter:',
|
||||||
});
|
});
|
||||||
limiterOptions.store = store;
|
limiterOptions.store = store;
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
const Keyv = require('keyv');
|
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
const { RedisStore } = require('rate-limit-redis');
|
||||||
const denyRequest = require('~/server/middleware/denyRequest');
|
const denyRequest = require('~/server/middleware/denyRequest');
|
||||||
|
const ioredisClient = require('~/cache/ioredisClient');
|
||||||
const { isEnabled } = require('~/server/utils');
|
const { isEnabled } = require('~/server/utils');
|
||||||
const keyvRedis = require('~/cache/keyvRedis');
|
|
||||||
const { logViolation } = require('~/cache');
|
const { logViolation } = require('~/cache');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
@@ -63,11 +62,9 @@ const userLimiterOptions = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEnabled(process.env.USE_REDIS)) {
|
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
||||||
logger.debug('Using Redis for message rate limiters.');
|
logger.debug('Using Redis for message rate limiters.');
|
||||||
const keyv = new Keyv({ store: keyvRedis });
|
const sendCommand = (...args) => ioredisClient.call(...args);
|
||||||
const client = keyv.opts.store.redis;
|
|
||||||
const sendCommand = (...args) => client.call(...args);
|
|
||||||
const ipStore = new RedisStore({
|
const ipStore = new RedisStore({
|
||||||
sendCommand,
|
sendCommand,
|
||||||
prefix: 'message_ip_limiter:',
|
prefix: 'message_ip_limiter:',
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
const Keyv = require('keyv');
|
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
const { RedisStore } = require('rate-limit-redis');
|
||||||
const { removePorts, isEnabled } = require('~/server/utils');
|
const { removePorts, isEnabled } = require('~/server/utils');
|
||||||
const keyvRedis = require('~/cache/keyvRedis');
|
const ioredisClient = require('~/cache/ioredisClient');
|
||||||
const { logViolation } = require('~/cache');
|
const { logViolation } = require('~/cache');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
@@ -31,13 +30,10 @@ const limiterOptions = {
|
|||||||
keyGenerator: removePorts,
|
keyGenerator: removePorts,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEnabled(process.env.USE_REDIS)) {
|
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
||||||
logger.debug('Using Redis for register rate limiter.');
|
logger.debug('Using Redis for register rate limiter.');
|
||||||
const keyv = new Keyv({ store: keyvRedis });
|
|
||||||
const client = keyv.opts.store.redis;
|
|
||||||
const sendCommand = (...args) => client.call(...args);
|
|
||||||
const store = new RedisStore({
|
const store = new RedisStore({
|
||||||
sendCommand,
|
sendCommand: (...args) => ioredisClient.call(...args),
|
||||||
prefix: 'register_limiter:',
|
prefix: 'register_limiter:',
|
||||||
});
|
});
|
||||||
limiterOptions.store = store;
|
limiterOptions.store = store;
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
const Keyv = require('keyv');
|
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
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, isEnabled } = require('~/server/utils');
|
||||||
const keyvRedis = require('~/cache/keyvRedis');
|
const ioredisClient = require('~/cache/ioredisClient');
|
||||||
const { logViolation } = require('~/cache');
|
const { logViolation } = require('~/cache');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
@@ -36,13 +35,10 @@ const limiterOptions = {
|
|||||||
keyGenerator: removePorts,
|
keyGenerator: removePorts,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEnabled(process.env.USE_REDIS)) {
|
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
||||||
logger.debug('Using Redis for reset password rate limiter.');
|
logger.debug('Using Redis for reset password rate limiter.');
|
||||||
const keyv = new Keyv({ store: keyvRedis });
|
|
||||||
const client = keyv.opts.store.redis;
|
|
||||||
const sendCommand = (...args) => client.call(...args);
|
|
||||||
const store = new RedisStore({
|
const store = new RedisStore({
|
||||||
sendCommand,
|
sendCommand: (...args) => ioredisClient.call(...args),
|
||||||
prefix: 'reset_password_limiter:',
|
prefix: 'reset_password_limiter:',
|
||||||
});
|
});
|
||||||
limiterOptions.store = store;
|
limiterOptions.store = store;
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
const Keyv = require('keyv');
|
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
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 { isEnabled } = require('~/server/utils');
|
||||||
const keyvRedis = require('~/cache/keyvRedis');
|
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
const getEnvironmentVariables = () => {
|
const getEnvironmentVariables = () => {
|
||||||
@@ -67,11 +66,9 @@ const createSTTLimiters = () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEnabled(process.env.USE_REDIS)) {
|
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
||||||
logger.debug('Using Redis for STT rate limiters.');
|
logger.debug('Using Redis for STT rate limiters.');
|
||||||
const keyv = new Keyv({ store: keyvRedis });
|
const sendCommand = (...args) => ioredisClient.call(...args);
|
||||||
const client = keyv.opts.store.redis;
|
|
||||||
const sendCommand = (...args) => client.call(...args);
|
|
||||||
const ipStore = new RedisStore({
|
const ipStore = new RedisStore({
|
||||||
sendCommand,
|
sendCommand,
|
||||||
prefix: 'stt_ip_limiter:',
|
prefix: 'stt_ip_limiter:',
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
const Keyv = require('keyv');
|
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
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 { isEnabled } = require('~/server/utils');
|
||||||
const keyvRedis = require('~/cache/keyvRedis');
|
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
const handler = async (req, res) => {
|
const handler = async (req, res) => {
|
||||||
@@ -29,13 +28,10 @@ const limiterOptions = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEnabled(process.env.USE_REDIS)) {
|
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
||||||
logger.debug('Using Redis for tool call rate limiter.');
|
logger.debug('Using Redis for tool call rate limiter.');
|
||||||
const keyv = new Keyv({ store: keyvRedis });
|
|
||||||
const client = keyv.opts.store.redis;
|
|
||||||
const sendCommand = (...args) => client.call(...args);
|
|
||||||
const store = new RedisStore({
|
const store = new RedisStore({
|
||||||
sendCommand,
|
sendCommand: (...args) => ioredisClient.call(...args),
|
||||||
prefix: 'tool_call_limiter:',
|
prefix: 'tool_call_limiter:',
|
||||||
});
|
});
|
||||||
limiterOptions.store = store;
|
limiterOptions.store = store;
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
const Keyv = require('keyv');
|
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
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 { isEnabled } = require('~/server/utils');
|
||||||
const keyvRedis = require('~/cache/keyvRedis');
|
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
const getEnvironmentVariables = () => {
|
const getEnvironmentVariables = () => {
|
||||||
@@ -67,11 +66,9 @@ const createTTSLimiters = () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEnabled(process.env.USE_REDIS)) {
|
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
||||||
logger.debug('Using Redis for TTS rate limiters.');
|
logger.debug('Using Redis for TTS rate limiters.');
|
||||||
const keyv = new Keyv({ store: keyvRedis });
|
const sendCommand = (...args) => ioredisClient.call(...args);
|
||||||
const client = keyv.opts.store.redis;
|
|
||||||
const sendCommand = (...args) => client.call(...args);
|
|
||||||
const ipStore = new RedisStore({
|
const ipStore = new RedisStore({
|
||||||
sendCommand,
|
sendCommand,
|
||||||
prefix: 'tts_ip_limiter:',
|
prefix: 'tts_ip_limiter:',
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
const Keyv = require('keyv');
|
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
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 { isEnabled } = require('~/server/utils');
|
||||||
const keyvRedis = require('~/cache/keyvRedis');
|
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
const getEnvironmentVariables = () => {
|
const getEnvironmentVariables = () => {
|
||||||
@@ -72,11 +71,9 @@ const createFileLimiters = () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEnabled(process.env.USE_REDIS)) {
|
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
||||||
logger.debug('Using Redis for file upload rate limiters.');
|
logger.debug('Using Redis for file upload rate limiters.');
|
||||||
const keyv = new Keyv({ store: keyvRedis });
|
const sendCommand = (...args) => ioredisClient.call(...args);
|
||||||
const client = keyv.opts.store.redis;
|
|
||||||
const sendCommand = (...args) => client.call(...args);
|
|
||||||
const ipStore = new RedisStore({
|
const ipStore = new RedisStore({
|
||||||
sendCommand,
|
sendCommand,
|
||||||
prefix: 'file_upload_ip_limiter:',
|
prefix: 'file_upload_ip_limiter:',
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
const Keyv = require('keyv');
|
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
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, isEnabled } = require('~/server/utils');
|
||||||
const keyvRedis = require('~/cache/keyvRedis');
|
const ioredisClient = require('~/cache/ioredisClient');
|
||||||
const { logViolation } = require('~/cache');
|
const { logViolation } = require('~/cache');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
@@ -36,13 +35,10 @@ const limiterOptions = {
|
|||||||
keyGenerator: removePorts,
|
keyGenerator: removePorts,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEnabled(process.env.USE_REDIS)) {
|
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
||||||
logger.debug('Using Redis for verify email rate limiter.');
|
logger.debug('Using Redis for verify email rate limiter.');
|
||||||
const keyv = new Keyv({ store: keyvRedis });
|
|
||||||
const client = keyv.opts.store.redis;
|
|
||||||
const sendCommand = (...args) => client.call(...args);
|
|
||||||
const store = new RedisStore({
|
const store = new RedisStore({
|
||||||
sendCommand,
|
sendCommand: (...args) => ioredisClient.call(...args),
|
||||||
prefix: 'verify_email_limiter:',
|
prefix: 'verify_email_limiter:',
|
||||||
});
|
});
|
||||||
limiterOptions.store = store;
|
limiterOptions.store = store;
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ const checkAccess = async (user, permissionType, permissions, bodyProps = {}, ch
|
|||||||
}
|
}
|
||||||
|
|
||||||
const role = await getRoleByName(user.role);
|
const role = await getRoleByName(user.role);
|
||||||
if (role && role[permissionType]) {
|
if (role && role.permissions && role.permissions[permissionType]) {
|
||||||
const hasAnyPermission = permissions.some((permission) => {
|
const hasAnyPermission = permissions.some((permission) => {
|
||||||
if (role[permissionType][permission]) {
|
if (role.permissions[permissionType][permission]) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { CacheKeys } = require('librechat-data-provider');
|
||||||
const { getAccessToken } = require('~/server/services/TokenService');
|
const { getAccessToken } = require('~/server/services/TokenService');
|
||||||
const { logger, getFlowStateManager } = require('~/config');
|
const { logger, getFlowStateManager } = require('~/config');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
@@ -19,8 +20,8 @@ const JWT_SECRET = process.env.JWT_SECRET;
|
|||||||
router.get('/:action_id/oauth/callback', async (req, res) => {
|
router.get('/:action_id/oauth/callback', async (req, res) => {
|
||||||
const { action_id } = req.params;
|
const { action_id } = req.params;
|
||||||
const { code, state } = req.query;
|
const { code, state } = req.query;
|
||||||
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||||
const flowManager = await getFlowStateManager(getLogStores);
|
const flowManager = getFlowStateManager(flowsCache);
|
||||||
let identifier = action_id;
|
let identifier = action_id;
|
||||||
try {
|
try {
|
||||||
let decodedState;
|
let decodedState;
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ router.post('/:agent_id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let { domain } = metadata;
|
let { domain } = metadata;
|
||||||
domain = await domainParser(req, domain, true);
|
domain = await domainParser(domain, true);
|
||||||
|
|
||||||
if (!domain) {
|
if (!domain) {
|
||||||
return res.status(400).json({ message: 'No domain provided' });
|
return res.status(400).json({ message: 'No domain provided' });
|
||||||
@@ -164,7 +164,7 @@ router.delete('/:agent_id/:action_id', async (req, res) => {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
domain = await domainParser(req, domain, true);
|
domain = await domainParser(domain, true);
|
||||||
|
|
||||||
if (!domain) {
|
if (!domain) {
|
||||||
return res.status(400).json({ message: 'No domain provided' });
|
return res.status(400).json({ message: 'No domain provided' });
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ const express = require('express');
|
|||||||
const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
||||||
const {
|
const {
|
||||||
setHeaders,
|
setHeaders,
|
||||||
handleAbort,
|
|
||||||
moderateText,
|
moderateText,
|
||||||
// validateModel,
|
// validateModel,
|
||||||
generateCheckAccess,
|
generateCheckAccess,
|
||||||
@@ -16,28 +15,36 @@ const addTitle = require('~/server/services/Endpoints/agents/title');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.use(moderateText);
|
router.use(moderateText);
|
||||||
router.post('/abort', handleAbort());
|
|
||||||
|
|
||||||
const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
|
const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
|
||||||
|
|
||||||
|
router.use(checkAgentAccess);
|
||||||
|
router.use(validateConvoAccess);
|
||||||
|
router.use(buildEndpointOption);
|
||||||
|
router.use(setHeaders);
|
||||||
|
|
||||||
|
const controller = async (req, res, next) => {
|
||||||
|
await AgentController(req, res, next, initializeClient, addTitle);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route POST /
|
* @route POST / (regular endpoint)
|
||||||
* @desc Chat with an assistant
|
* @desc Chat with an assistant
|
||||||
* @access Public
|
* @access Public
|
||||||
* @param {express.Request} req - The request object, containing the request data.
|
* @param {express.Request} req - The request object, containing the request data.
|
||||||
* @param {express.Response} res - The response object, used to send back a response.
|
* @param {express.Response} res - The response object, used to send back a response.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
router.post(
|
router.post('/', controller);
|
||||||
'/',
|
|
||||||
// validateModel,
|
/**
|
||||||
checkAgentAccess,
|
* @route POST /:endpoint (ephemeral agents)
|
||||||
validateConvoAccess,
|
* @desc Chat with an assistant
|
||||||
buildEndpointOption,
|
* @access Public
|
||||||
setHeaders,
|
* @param {express.Request} req - The request object, containing the request data.
|
||||||
async (req, res, next) => {
|
* @param {express.Response} res - The response object, used to send back a response.
|
||||||
await AgentController(req, res, next, initializeClient, addTitle);
|
* @returns {void}
|
||||||
},
|
*/
|
||||||
);
|
router.post('/:endpoint', controller);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ router.post('/:id/duplicate', checkAgentCreate, v1.duplicateAgent);
|
|||||||
*/
|
*/
|
||||||
router.delete('/:id', checkAgentCreate, v1.deleteAgent);
|
router.delete('/:id', checkAgentCreate, v1.deleteAgent);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverts an agent to a previous version.
|
||||||
|
* @route POST /agents/:id/revert
|
||||||
|
* @param {string} req.params.id - Agent identifier.
|
||||||
|
* @param {number} req.body.version_index - Index of the version to revert to.
|
||||||
|
* @returns {Agent} 200 - success response - application/json
|
||||||
|
*/
|
||||||
|
router.post('/:id/revert', checkGlobalAgentShare, v1.revertAgentVersion);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a list of agents.
|
* Returns a list of agents.
|
||||||
* @route GET /agents
|
* @route GET /agents
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const Keyv = require('keyv');
|
const { Keyv } = require('keyv');
|
||||||
const { KeyvFile } = require('keyv-file');
|
const { KeyvFile } = require('keyv-file');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ const {
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.post('/abort', handleAbort());
|
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
validateEndpoint,
|
validateEndpoint,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ const AskController = require('~/server/controllers/AskController');
|
|||||||
const { initializeClient } = require('~/server/services/Endpoints/custom');
|
const { initializeClient } = require('~/server/services/Endpoints/custom');
|
||||||
const { addTitle } = require('~/server/services/Endpoints/openAI');
|
const { addTitle } = require('~/server/services/Endpoints/openAI');
|
||||||
const {
|
const {
|
||||||
handleAbort,
|
|
||||||
setHeaders,
|
setHeaders,
|
||||||
validateModel,
|
validateModel,
|
||||||
validateEndpoint,
|
validateEndpoint,
|
||||||
@@ -12,8 +11,6 @@ const {
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.post('/abort', handleAbort());
|
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
validateEndpoint,
|
validateEndpoint,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ const AskController = require('~/server/controllers/AskController');
|
|||||||
const { initializeClient, addTitle } = require('~/server/services/Endpoints/google');
|
const { initializeClient, addTitle } = require('~/server/services/Endpoints/google');
|
||||||
const {
|
const {
|
||||||
setHeaders,
|
setHeaders,
|
||||||
handleAbort,
|
|
||||||
validateModel,
|
validateModel,
|
||||||
validateEndpoint,
|
validateEndpoint,
|
||||||
buildEndpointOption,
|
buildEndpointOption,
|
||||||
@@ -11,8 +10,6 @@ const {
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.post('/abort', handleAbort());
|
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
validateEndpoint,
|
validateEndpoint,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ const { logger } = require('~/config');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.use(moderateText);
|
router.use(moderateText);
|
||||||
router.post('/abort', handleAbort());
|
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
@@ -196,7 +195,8 @@ router.post(
|
|||||||
|
|
||||||
logger.debug('[/ask/gptPlugins]', response);
|
logger.debug('[/ask/gptPlugins]', response);
|
||||||
|
|
||||||
const { conversation = {} } = await client.responsePromise;
|
const { conversation = {} } = await response.databasePromise;
|
||||||
|
delete response.databasePromise;
|
||||||
conversation.title =
|
conversation.title =
|
||||||
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
|
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const {
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(moderateText);
|
router.use(moderateText);
|
||||||
router.post('/abort', handleAbort());
|
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ router.post('/:assistant_id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let { domain } = metadata;
|
let { domain } = metadata;
|
||||||
domain = await domainParser(req, domain, true);
|
domain = await domainParser(domain, true);
|
||||||
|
|
||||||
if (!domain) {
|
if (!domain) {
|
||||||
return res.status(400).json({ message: 'No domain provided' });
|
return res.status(400).json({ message: 'No domain provided' });
|
||||||
@@ -172,7 +172,7 @@ router.delete('/:assistant_id/:action_id/:model', async (req, res) => {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
domain = await domainParser(req, domain, true);
|
domain = await domainParser(domain, true);
|
||||||
|
|
||||||
if (!domain) {
|
if (!domain) {
|
||||||
return res.status(400).json({ message: 'No domain provided' });
|
return res.status(400).json({ message: 'No domain provided' });
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const AgentController = require('~/server/controllers/agents/request');
|
|||||||
const addTitle = require('~/server/services/Endpoints/agents/title');
|
const addTitle = require('~/server/services/Endpoints/agents/title');
|
||||||
|
|
||||||
router.use(moderateText);
|
router.use(moderateText);
|
||||||
router.post('/abort', handleAbort());
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route POST /
|
* @route POST /
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ router.get('/', async function (req, res) {
|
|||||||
process.env.SHOW_BIRTHDAY_ICON === '',
|
process.env.SHOW_BIRTHDAY_ICON === '',
|
||||||
helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai',
|
helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai',
|
||||||
interface: req.app.locals.interfaceConfig,
|
interface: req.app.locals.interfaceConfig,
|
||||||
|
turnstile: req.app.locals.turnstileConfig,
|
||||||
modelSpecs: req.app.locals.modelSpecs,
|
modelSpecs: req.app.locals.modelSpecs,
|
||||||
balance: req.app.locals.balance,
|
balance: req.app.locals.balance,
|
||||||
sharedLinksEnabled,
|
sharedLinksEnabled,
|
||||||
@@ -82,6 +83,7 @@ router.get('/', async function (req, res) {
|
|||||||
analyticsGtmId: process.env.ANALYTICS_GTM_ID,
|
analyticsGtmId: process.env.ANALYTICS_GTM_ID,
|
||||||
instanceProjectId: instanceProject._id.toString(),
|
instanceProjectId: instanceProject._id.toString(),
|
||||||
bundlerURL: process.env.SANDPACK_BUNDLER_URL,
|
bundlerURL: process.env.SANDPACK_BUNDLER_URL,
|
||||||
|
staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (ldap) {
|
if (ldap) {
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
|
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
|
||||||
const { getConvosByPage, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation');
|
const { getConvosByCursor, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation');
|
||||||
const { forkConversation, duplicateConversation } = require('~/server/utils/import/fork');
|
const { forkConversation, duplicateConversation } = require('~/server/utils/import/fork');
|
||||||
const { storage, importFileFilter } = require('~/server/routes/files/multer');
|
const { storage, importFileFilter } = require('~/server/routes/files/multer');
|
||||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||||
const { importConversations } = require('~/server/utils/import');
|
const { importConversations } = require('~/server/utils/import');
|
||||||
const { createImportLimiters } = require('~/server/middleware');
|
const { createImportLimiters } = require('~/server/middleware');
|
||||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||||
|
const { isEnabled, sleep } = require('~/server/utils');
|
||||||
const getLogStores = require('~/cache/getLogStores');
|
const getLogStores = require('~/cache/getLogStores');
|
||||||
const { sleep } = require('~/server/utils');
|
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
const assistantClients = {
|
const assistantClients = {
|
||||||
[EModelEndpoint.azureAssistants]: require('~/server/services/Endpoints/azureAssistants'),
|
[EModelEndpoint.azureAssistants]: require('~/server/services/Endpoints/azureAssistants'),
|
||||||
[EModelEndpoint.assistants]: require('~/server/services/Endpoints/assistants'),
|
[EModelEndpoint.assistants]: require('~/server/services/Endpoints/assistants'),
|
||||||
@@ -20,28 +21,30 @@ const router = express.Router();
|
|||||||
router.use(requireJwtAuth);
|
router.use(requireJwtAuth);
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
let pageNumber = req.query.pageNumber || 1;
|
const limit = parseInt(req.query.limit, 10) || 25;
|
||||||
pageNumber = parseInt(pageNumber, 10);
|
const cursor = req.query.cursor;
|
||||||
|
const isArchived = isEnabled(req.query.isArchived);
|
||||||
|
const search = req.query.search ? decodeURIComponent(req.query.search) : undefined;
|
||||||
|
const order = req.query.order || 'desc';
|
||||||
|
|
||||||
if (isNaN(pageNumber) || pageNumber < 1) {
|
|
||||||
return res.status(400).json({ error: 'Invalid page number' });
|
|
||||||
}
|
|
||||||
|
|
||||||
let pageSize = req.query.pageSize || 25;
|
|
||||||
pageSize = parseInt(pageSize, 10);
|
|
||||||
|
|
||||||
if (isNaN(pageSize) || pageSize < 1) {
|
|
||||||
return res.status(400).json({ error: 'Invalid page size' });
|
|
||||||
}
|
|
||||||
const isArchived = req.query.isArchived === 'true';
|
|
||||||
let tags;
|
let tags;
|
||||||
if (req.query.tags) {
|
if (req.query.tags) {
|
||||||
tags = Array.isArray(req.query.tags) ? req.query.tags : [req.query.tags];
|
tags = Array.isArray(req.query.tags) ? req.query.tags : [req.query.tags];
|
||||||
} else {
|
|
||||||
tags = undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).send(await getConvosByPage(req.user.id, pageNumber, pageSize, isArchived, tags));
|
try {
|
||||||
|
const result = await getConvosByCursor(req.user.id, {
|
||||||
|
cursor,
|
||||||
|
limit,
|
||||||
|
isArchived,
|
||||||
|
tags,
|
||||||
|
search,
|
||||||
|
order,
|
||||||
|
});
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Error fetching conversations' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/:conversationId', async (req, res) => {
|
router.get('/:conversationId', async (req, res) => {
|
||||||
@@ -76,22 +79,28 @@ router.post('/gen_title', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/clear', async (req, res) => {
|
router.delete('/', async (req, res) => {
|
||||||
let filter = {};
|
let filter = {};
|
||||||
const { conversationId, source, thread_id, endpoint } = req.body.arg;
|
const { conversationId, source, thread_id, endpoint } = req.body.arg;
|
||||||
if (conversationId) {
|
|
||||||
filter = { conversationId };
|
// Prevent deletion of all conversations
|
||||||
|
if (!conversationId && !source && !thread_id && !endpoint) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'no parameters provided',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (source === 'button' && !conversationId) {
|
if (conversationId) {
|
||||||
|
filter = { conversationId };
|
||||||
|
} else if (source === 'button') {
|
||||||
return res.status(200).send('No conversationId provided');
|
return res.status(200).send('No conversationId provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof endpoint != 'undefined' &&
|
typeof endpoint !== 'undefined' &&
|
||||||
Object.prototype.propertyIsEnumerable.call(assistantClients, endpoint)
|
Object.prototype.propertyIsEnumerable.call(assistantClients, endpoint)
|
||||||
) {
|
) {
|
||||||
/** @type {{ openai: OpenAI}} */
|
/** @type {{ openai: OpenAI }} */
|
||||||
const { openai } = await assistantClients[endpoint].initializeClient({ req, res });
|
const { openai } = await assistantClients[endpoint].initializeClient({ req, res });
|
||||||
try {
|
try {
|
||||||
const response = await openai.beta.threads.del(thread_id);
|
const response = await openai.beta.threads.del(thread_id);
|
||||||
@@ -101,9 +110,6 @@ router.post('/clear', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// for debugging deletion source
|
|
||||||
// logger.debug('source:', source);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dbResponse = await deleteConvos(req.user.id, filter);
|
const dbResponse = await deleteConvos(req.user.id, filter);
|
||||||
await deleteToolCalls(req.user.id, filter.conversationId);
|
await deleteToolCalls(req.user.id, filter.conversationId);
|
||||||
@@ -114,6 +120,17 @@ router.post('/clear', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.delete('/all', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const dbResponse = await deleteConvos(req.user.id, {});
|
||||||
|
await deleteToolCalls(req.user.id);
|
||||||
|
res.status(201).json(dbResponse);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error clearing conversations', error);
|
||||||
|
res.status(500).send('Error clearing conversations');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/update', async (req, res) => {
|
router.post('/update', async (req, res) => {
|
||||||
const update = req.body.arg;
|
const update = req.body.arg;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ const EditController = require('~/server/controllers/EditController');
|
|||||||
const { initializeClient } = require('~/server/services/Endpoints/anthropic');
|
const { initializeClient } = require('~/server/services/Endpoints/anthropic');
|
||||||
const {
|
const {
|
||||||
setHeaders,
|
setHeaders,
|
||||||
handleAbort,
|
|
||||||
validateModel,
|
validateModel,
|
||||||
validateEndpoint,
|
validateEndpoint,
|
||||||
buildEndpointOption,
|
buildEndpointOption,
|
||||||
@@ -11,8 +10,6 @@ const {
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.post('/abort', handleAbort());
|
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
validateEndpoint,
|
validateEndpoint,
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ const {
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.post('/abort', handleAbort());
|
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
validateEndpoint,
|
validateEndpoint,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ const EditController = require('~/server/controllers/EditController');
|
|||||||
const { initializeClient } = require('~/server/services/Endpoints/google');
|
const { initializeClient } = require('~/server/services/Endpoints/google');
|
||||||
const {
|
const {
|
||||||
setHeaders,
|
setHeaders,
|
||||||
handleAbort,
|
|
||||||
validateModel,
|
validateModel,
|
||||||
validateEndpoint,
|
validateEndpoint,
|
||||||
buildEndpointOption,
|
buildEndpointOption,
|
||||||
@@ -11,8 +10,6 @@ const {
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.post('/abort', handleAbort());
|
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
validateEndpoint,
|
validateEndpoint,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ const express = require('express');
|
|||||||
const { getResponseSender } = require('librechat-data-provider');
|
const { getResponseSender } = require('librechat-data-provider');
|
||||||
const {
|
const {
|
||||||
setHeaders,
|
setHeaders,
|
||||||
handleAbort,
|
|
||||||
moderateText,
|
moderateText,
|
||||||
validateModel,
|
validateModel,
|
||||||
handleAbortError,
|
handleAbortError,
|
||||||
@@ -19,7 +18,6 @@ const { logger } = require('~/config');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.use(moderateText);
|
router.use(moderateText);
|
||||||
router.post('/abort', handleAbort());
|
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
@@ -173,7 +171,8 @@ router.post(
|
|||||||
|
|
||||||
logger.debug('[/edit/gptPlugins] CLIENT RESPONSE', response);
|
logger.debug('[/edit/gptPlugins] CLIENT RESPONSE', response);
|
||||||
|
|
||||||
const { conversation = {} } = await client.responsePromise;
|
const { conversation = {} } = await response.databasePromise;
|
||||||
|
delete response.databasePromise;
|
||||||
conversation.title =
|
conversation.title =
|
||||||
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
|
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ const express = require('express');
|
|||||||
const EditController = require('~/server/controllers/EditController');
|
const EditController = require('~/server/controllers/EditController');
|
||||||
const { initializeClient } = require('~/server/services/Endpoints/openAI');
|
const { initializeClient } = require('~/server/services/Endpoints/openAI');
|
||||||
const {
|
const {
|
||||||
handleAbort,
|
|
||||||
setHeaders,
|
setHeaders,
|
||||||
validateModel,
|
validateModel,
|
||||||
validateEndpoint,
|
validateEndpoint,
|
||||||
@@ -12,7 +11,6 @@ const {
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(moderateText);
|
router.use(moderateText);
|
||||||
router.post('/abort', handleAbort());
|
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user