mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-25 03:36:12 +01:00
Compare commits
264 commits
v0.8.1-rc2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18a0e8a8b0 | ||
|
|
0cf7bb2d0e | ||
|
|
cfd5c793a9 | ||
|
|
7204e74390 | ||
|
|
7f59a1815c | ||
|
|
74cc001e40 | ||
|
|
639a60cf19 | ||
|
|
191cd3983c | ||
|
|
11210d8b98 | ||
|
|
dea246934e | ||
|
|
9d612715a5 | ||
|
|
e2ec3f18c9 | ||
|
|
12ec64b988 | ||
|
|
828c2b2048 | ||
|
|
e608c652e5 | ||
|
|
24e182d20e | ||
|
|
c5113a75a0 | ||
|
|
f09eec8462 | ||
|
|
36c5a88c4e | ||
|
|
32e6f3b8e5 | ||
|
|
e509ba5be0 | ||
|
|
4a1d2b0d94 | ||
|
|
9134471143 | ||
|
|
277fbd10cb | ||
|
|
b70528f59a | ||
|
|
66d4540217 | ||
|
|
5037617131 | ||
|
|
922cdafe81 | ||
|
|
c11245f74b | ||
|
|
f7893d9507 | ||
|
|
02d75b24a4 | ||
|
|
c378e777ef | ||
|
|
81f4af55b5 | ||
|
|
476882455e | ||
|
|
bb0fa3b7f7 | ||
|
|
9562f9297a | ||
|
|
b5e4c763af | ||
|
|
39a227a59f | ||
|
|
8d74fcd44a | ||
|
|
9d5e80d7a3 | ||
|
|
a95fea19bb | ||
|
|
10f591ab1c | ||
|
|
774f1f2cc2 | ||
|
|
5617bf71be | ||
|
|
2a50c372ef | ||
|
|
1329e16d3a | ||
|
|
f8774983a0 | ||
|
|
a8fa85b8e2 | ||
|
|
28270bec58 | ||
|
|
90521bfb4e | ||
|
|
fc6f127b21 | ||
|
|
cdffdd2926 | ||
|
|
2958fcd0c5 | ||
|
|
200377947e | ||
|
|
76e17ba701 | ||
|
|
083251508e | ||
|
|
7d38047bc2 | ||
|
|
87c817a5eb | ||
|
|
f2e4cd5026 | ||
|
|
6680ccf63b | ||
|
|
c30afb8b68 | ||
|
|
24e8a258cd | ||
|
|
9845b3148e | ||
|
|
9434d4a070 | ||
|
|
a95fccc5f3 | ||
|
|
348b4a4a32 | ||
|
|
3b41e392ba | ||
|
|
a7645f4705 | ||
|
|
b5aa38ff33 | ||
|
|
04fd231b61 | ||
|
|
35137c21e6 | ||
|
|
f8383f2fc8 | ||
|
|
d21dfba2ac | ||
|
|
019c59f10e | ||
|
|
e343180740 | ||
|
|
1544491737 | ||
|
|
ca58d70c44 | ||
|
|
b7db0dd9bc | ||
|
|
211b39f311 | ||
|
|
4d6ea3b182 | ||
|
|
200098d992 | ||
|
|
e452c1a8d9 | ||
|
|
b94388ce9d | ||
|
|
cda6d589d6 | ||
|
|
b1a2b96276 | ||
|
|
1e74dc231f | ||
|
|
f3aec0576d | ||
|
|
7d136edb40 | ||
|
|
791dab8f20 | ||
|
|
d3b5020dd9 | ||
|
|
a7aa4dc91b | ||
|
|
a2361aa891 | ||
|
|
e4b879c655 | ||
|
|
90c63a56f3 | ||
|
|
716d2a9f3c | ||
|
|
bed1923990 | ||
|
|
f9501d2a42 | ||
|
|
dcda6a249c | ||
|
|
c7469ce884 | ||
|
|
180d0f18fe | ||
|
|
512fed56bf | ||
|
|
131eab7bff | ||
|
|
eb1a59d2fd | ||
|
|
06ba025bd9 | ||
|
|
4b9c6ab1cb | ||
|
|
4fd09946d2 | ||
|
|
a59bab4dc7 | ||
|
|
28f4800e95 | ||
|
|
47a0f113a7 | ||
|
|
bf00909a8c | ||
|
|
29275cdc2a | ||
|
|
d0835d5222 | ||
|
|
e4870ed0b0 | ||
|
|
0b8e0fcede | ||
|
|
eeb5522464 | ||
|
|
c7b2d42279 | ||
|
|
e854967e94 | ||
|
|
5181356bef | ||
|
|
c21733930c | ||
|
|
daed6d9c0e | ||
|
|
3503b7caeb | ||
|
|
43c2c20dd7 | ||
|
|
f993189e66 | ||
|
|
8a4c2931f6 | ||
|
|
b9792160e2 | ||
|
|
d7a765ac4c | ||
|
|
7183223e59 | ||
|
|
4fe223eedd | ||
|
|
5caa008432 | ||
|
|
bfc981d736 | ||
|
|
b7ea340769 | ||
|
|
6ffb176056 | ||
|
|
d0863de8d4 | ||
|
|
7844a93f8b | ||
|
|
d7ff507ff4 | ||
|
|
439bc98682 | ||
|
|
9b6e7cabc9 | ||
|
|
5740ca59d8 | ||
|
|
0ae3b87b65 | ||
|
|
25a0ebee85 | ||
|
|
cd5299807b | ||
|
|
e352f8d3fb | ||
|
|
7ef975e975 | ||
|
|
9dda857a59 | ||
|
|
41f815c037 | ||
|
|
95a69df70e | ||
|
|
98294755ee | ||
|
|
1f695e0cdc | ||
|
|
afb67fcf16 | ||
|
|
da10815566 | ||
|
|
73c2ed18c8 | ||
|
|
d505e3124f | ||
|
|
b4459ab564 | ||
|
|
a0df7e8df1 | ||
|
|
8b5ef15071 | ||
|
|
d8b788aecc | ||
|
|
23279b4b14 | ||
|
|
5bfebc7c9d | ||
|
|
f9060fa25f | ||
|
|
e53619959d | ||
|
|
03ced7a894 | ||
|
|
dcd9273700 | ||
|
|
02fc4647e1 | ||
|
|
6ae839c14d | ||
|
|
f11817a30e | ||
|
|
3213f574c6 | ||
|
|
b5ab32c5ae | ||
|
|
06719794f6 | ||
|
|
4d7e6b4a58 | ||
|
|
5b0cce2e2a | ||
|
|
959e301f99 | ||
|
|
e15d37b399 | ||
|
|
abeaab6e17 | ||
|
|
ef96ce2b4b | ||
|
|
ad733157d7 | ||
|
|
304bba853c | ||
|
|
4a0fbb07bc | ||
|
|
abcf606328 | ||
|
|
70e854eb59 | ||
|
|
27edfc8710 | ||
|
|
6e928cc468 | ||
|
|
a76b2d364b | ||
|
|
da9b5196aa | ||
|
|
6fc6471010 | ||
|
|
9e67eee294 | ||
|
|
97650ffb3f | ||
|
|
2ed2b87c30 | ||
|
|
d08f7c2c8a | ||
|
|
885508fc74 | ||
|
|
b97d72e51a | ||
|
|
b4b5a2cd69 | ||
|
|
9400148175 | ||
|
|
394bb6242b | ||
|
|
e6288c379c | ||
|
|
99f8bd2ce6 | ||
|
|
41c0a96d39 | ||
|
|
470a73b406 | ||
|
|
b6e5ea5d33 | ||
|
|
cea4f57a73 | ||
|
|
5b3cef6d86 | ||
|
|
04a4a2aa44 | ||
|
|
1a11b64266 | ||
|
|
a725fb34da | ||
|
|
5fac4ffd1c | ||
|
|
69200623c2 | ||
|
|
9df4d272e1 | ||
|
|
f856da8391 | ||
|
|
20256d72fc | ||
|
|
1a38e2a081 | ||
|
|
ad6ba4b6d1 | ||
|
|
da473bf43a | ||
|
|
67952372d0 | ||
|
|
1e39808408 | ||
|
|
9fff229836 | ||
|
|
58f73626e7 | ||
|
|
b1e31fdc97 | ||
|
|
8d1f1c4dd4 | ||
|
|
ef1b7f0157 | ||
|
|
98b188f26c | ||
|
|
2989ebd649 | ||
|
|
ac68e629e6 | ||
|
|
52e6796635 | ||
|
|
656e1abaea | ||
|
|
b6dcefc53a | ||
|
|
39cecc97bd | ||
|
|
1143f73f59 | ||
|
|
b288d81f5a | ||
|
|
24c76c6cb9 | ||
|
|
4a2de417b6 | ||
|
|
03c9d5f79f | ||
|
|
af8394b05c | ||
|
|
6fe44ff116 | ||
|
|
e7bb987441 | ||
|
|
fa0f2472cc | ||
|
|
5879b3f518 | ||
|
|
11923b9b96 | ||
|
|
b4892d81d3 | ||
|
|
a07cc11cd6 | ||
|
|
b68d16bdea | ||
|
|
f55bd6f99b | ||
|
|
754b495fb8 | ||
|
|
2d536dd0fa | ||
|
|
711d21365d | ||
|
|
8bdc808074 | ||
|
|
b2387cc6fa | ||
|
|
28bdd0dfa6 | ||
|
|
1477da4987 | ||
|
|
ef5540f278 | ||
|
|
745c299563 | ||
|
|
3b35fa53d9 | ||
|
|
01413eea3d | ||
|
|
6fa94d3eb8 | ||
|
|
4202db1c99 | ||
|
|
026890cd27 | ||
|
|
6c0aad423f | ||
|
|
774ebd1eaa | ||
|
|
d5d362e52b | ||
|
|
d7ce19e15a | ||
|
|
2ccaf6be6d | ||
|
|
90f0bcde44 | ||
|
|
801c95a829 | ||
|
|
872dbb4151 | ||
|
|
cb2bee19b7 | ||
|
|
961d3b1d3b |
1146 changed files with 86450 additions and 31894 deletions
|
|
@ -20,8 +20,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
- MONGO_URI=mongodb://mongodb:27017/LibreChat
|
- MONGO_URI=mongodb://mongodb:27017/LibreChat
|
||||||
# - CHATGPT_REVERSE_PROXY=http://host.docker.internal:8080/api/conversation # if you are hosting your own chatgpt reverse proxy with docker
|
# - OPENAI_REVERSE_PROXY=http://host.docker.internal:8070/v1
|
||||||
# - OPENAI_REVERSE_PROXY=http://host.docker.internal:8070/v1/chat/completions # if you are hosting your own chatgpt reverse proxy with docker
|
|
||||||
- MEILI_HOST=http://meilisearch:7700
|
- MEILI_HOST=http://meilisearch:7700
|
||||||
|
|
||||||
# Runs app on the same network as the service container, allows "forwardPorts" in devcontainer.json function.
|
# Runs app on the same network as the service container, allows "forwardPorts" in devcontainer.json function.
|
||||||
|
|
|
||||||
64
.env.example
64
.env.example
|
|
@ -68,6 +68,18 @@ DEBUG_CONSOLE=false
|
||||||
# UID=1000
|
# UID=1000
|
||||||
# GID=1000
|
# GID=1000
|
||||||
|
|
||||||
|
#==============#
|
||||||
|
# Node Options #
|
||||||
|
#==============#
|
||||||
|
|
||||||
|
# NOTE: NODE_MAX_OLD_SPACE_SIZE is NOT recognized by Node.js directly.
|
||||||
|
# This variable is used as a build argument for Docker or CI/CD workflows,
|
||||||
|
# and is NOT used by Node.js to set the heap size at runtime.
|
||||||
|
# To configure Node.js memory, use NODE_OPTIONS, e.g.:
|
||||||
|
# NODE_OPTIONS="--max-old-space-size=6144"
|
||||||
|
# See: https://nodejs.org/api/cli.html#--max-old-space-sizesize-in-mib
|
||||||
|
NODE_MAX_OLD_SPACE_SIZE=6144
|
||||||
|
|
||||||
#===============#
|
#===============#
|
||||||
# Configuration #
|
# Configuration #
|
||||||
#===============#
|
#===============#
|
||||||
|
|
@ -112,6 +124,10 @@ ANTHROPIC_API_KEY=user_provided
|
||||||
# ANTHROPIC_MODELS=claude-opus-4-20250514,claude-sonnet-4-20250514,claude-3-7-sonnet-20250219,claude-3-5-sonnet-20241022,claude-3-5-haiku-20241022,claude-3-opus-20240229,claude-3-sonnet-20240229,claude-3-haiku-20240307
|
# ANTHROPIC_MODELS=claude-opus-4-20250514,claude-sonnet-4-20250514,claude-3-7-sonnet-20250219,claude-3-5-sonnet-20241022,claude-3-5-haiku-20241022,claude-3-opus-20240229,claude-3-sonnet-20240229,claude-3-haiku-20240307
|
||||||
# ANTHROPIC_REVERSE_PROXY=
|
# ANTHROPIC_REVERSE_PROXY=
|
||||||
|
|
||||||
|
# Set to true to use Anthropic models through Google Vertex AI instead of direct API
|
||||||
|
# ANTHROPIC_USE_VERTEX=
|
||||||
|
# ANTHROPIC_VERTEX_REGION=us-east5
|
||||||
|
|
||||||
#============#
|
#============#
|
||||||
# Azure #
|
# Azure #
|
||||||
#============#
|
#============#
|
||||||
|
|
@ -129,7 +145,6 @@ ANTHROPIC_API_KEY=user_provided
|
||||||
# AZURE_OPENAI_API_VERSION= # Deprecated
|
# AZURE_OPENAI_API_VERSION= # Deprecated
|
||||||
# AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME= # Deprecated
|
# AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME= # Deprecated
|
||||||
# AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME= # Deprecated
|
# AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME= # Deprecated
|
||||||
# PLUGINS_USE_AZURE="true" # Deprecated
|
|
||||||
|
|
||||||
#=================#
|
#=================#
|
||||||
# AWS Bedrock #
|
# AWS Bedrock #
|
||||||
|
|
@ -170,8 +185,16 @@ GOOGLE_KEY=user_provided
|
||||||
|
|
||||||
# GOOGLE_TITLE_MODEL=gemini-2.0-flash-lite-001
|
# GOOGLE_TITLE_MODEL=gemini-2.0-flash-lite-001
|
||||||
|
|
||||||
|
# Google Cloud region for Vertex AI (used by both chat and image generation)
|
||||||
# GOOGLE_LOC=us-central1
|
# GOOGLE_LOC=us-central1
|
||||||
|
|
||||||
|
# Alternative region env var for Gemini Image Generation
|
||||||
|
# GOOGLE_CLOUD_LOCATION=global
|
||||||
|
|
||||||
|
# Vertex AI Service Account Configuration
|
||||||
|
# Path to your Google Cloud service account JSON file
|
||||||
|
# GOOGLE_SERVICE_KEY_FILE=/path/to/service-account.json
|
||||||
|
|
||||||
# Google Safety Settings
|
# Google Safety Settings
|
||||||
# NOTE: These settings apply to both Vertex AI and Gemini API (AI Studio)
|
# NOTE: These settings apply to both Vertex AI and Gemini API (AI Studio)
|
||||||
#
|
#
|
||||||
|
|
@ -191,6 +214,27 @@ GOOGLE_KEY=user_provided
|
||||||
# GOOGLE_SAFETY_DANGEROUS_CONTENT=BLOCK_ONLY_HIGH
|
# GOOGLE_SAFETY_DANGEROUS_CONTENT=BLOCK_ONLY_HIGH
|
||||||
# GOOGLE_SAFETY_CIVIC_INTEGRITY=BLOCK_ONLY_HIGH
|
# GOOGLE_SAFETY_CIVIC_INTEGRITY=BLOCK_ONLY_HIGH
|
||||||
|
|
||||||
|
#========================#
|
||||||
|
# Gemini Image Generation #
|
||||||
|
#========================#
|
||||||
|
|
||||||
|
# Gemini Image Generation Tool (for Agents)
|
||||||
|
# Supports multiple authentication methods in priority order:
|
||||||
|
# 1. User-provided API key (via GUI)
|
||||||
|
# 2. GEMINI_API_KEY env var (admin-configured)
|
||||||
|
# 3. GOOGLE_KEY env var (shared with Google chat endpoint)
|
||||||
|
# 4. Vertex AI service account (via GOOGLE_SERVICE_KEY_FILE)
|
||||||
|
|
||||||
|
# Option A: Use dedicated Gemini API key for image generation
|
||||||
|
# GEMINI_API_KEY=your-gemini-api-key
|
||||||
|
|
||||||
|
# Option B: Use Vertex AI (no API key needed, uses service account)
|
||||||
|
# Set this to enable Vertex AI and allow tool without requiring API keys
|
||||||
|
# GEMINI_VERTEX_ENABLED=true
|
||||||
|
|
||||||
|
# Vertex AI model for image generation (defaults to gemini-2.5-flash-image)
|
||||||
|
# GEMINI_IMAGE_MODEL=gemini-2.5-flash-image
|
||||||
|
|
||||||
#============#
|
#============#
|
||||||
# OpenAI #
|
# OpenAI #
|
||||||
#============#
|
#============#
|
||||||
|
|
@ -230,14 +274,6 @@ ASSISTANTS_API_KEY=user_provided
|
||||||
# More info, including how to enable use of Assistants with Azure here:
|
# More info, including how to enable use of Assistants with Azure here:
|
||||||
# https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints/azure#using-assistants-with-azure
|
# https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints/azure#using-assistants-with-azure
|
||||||
|
|
||||||
#============#
|
|
||||||
# Plugins #
|
|
||||||
#============#
|
|
||||||
|
|
||||||
# PLUGIN_MODELS=gpt-4o,gpt-4o-mini,gpt-4,gpt-4-turbo-preview,gpt-4-0125-preview,gpt-4-1106-preview,gpt-4-0613,gpt-3.5-turbo,gpt-3.5-turbo-0125,gpt-3.5-turbo-1106,gpt-3.5-turbo-0613
|
|
||||||
|
|
||||||
DEBUG_PLUGINS=true
|
|
||||||
|
|
||||||
CREDS_KEY=f34be427ebb29de8d88c107a71546019685ed8b241d8f2ed00c3df97ad2566f0
|
CREDS_KEY=f34be427ebb29de8d88c107a71546019685ed8b241d8f2ed00c3df97ad2566f0
|
||||||
CREDS_IV=e2341419ec3dd3d19b13a1a87fafcbfb
|
CREDS_IV=e2341419ec3dd3d19b13a1a87fafcbfb
|
||||||
|
|
||||||
|
|
@ -257,6 +293,7 @@ AZURE_AI_SEARCH_SEARCH_OPTION_SELECT=
|
||||||
# IMAGE_GEN_OAI_API_KEY= # Create or reuse OpenAI API key for image generation tool
|
# IMAGE_GEN_OAI_API_KEY= # Create or reuse OpenAI API key for image generation tool
|
||||||
# IMAGE_GEN_OAI_BASEURL= # Custom OpenAI base URL for image generation tool
|
# IMAGE_GEN_OAI_BASEURL= # Custom OpenAI base URL for image generation tool
|
||||||
# IMAGE_GEN_OAI_AZURE_API_VERSION= # Custom Azure OpenAI deployments
|
# IMAGE_GEN_OAI_AZURE_API_VERSION= # Custom Azure OpenAI deployments
|
||||||
|
# IMAGE_GEN_OAI_MODEL=gpt-image-1 # OpenAI image model (e.g., gpt-image-1, gpt-image-1.5)
|
||||||
# IMAGE_GEN_OAI_DESCRIPTION=
|
# IMAGE_GEN_OAI_DESCRIPTION=
|
||||||
# IMAGE_GEN_OAI_DESCRIPTION_WITH_FILES=Custom description for image generation tool when files are present
|
# 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_GEN_OAI_DESCRIPTION_NO_FILES=Custom description for image generation tool when no files are present
|
||||||
|
|
@ -294,10 +331,6 @@ FLUX_API_BASE_URL=https://api.us1.bfl.ai
|
||||||
GOOGLE_SEARCH_API_KEY=
|
GOOGLE_SEARCH_API_KEY=
|
||||||
GOOGLE_CSE_ID=
|
GOOGLE_CSE_ID=
|
||||||
|
|
||||||
# YOUTUBE
|
|
||||||
#-----------------
|
|
||||||
YOUTUBE_API_KEY=
|
|
||||||
|
|
||||||
# Stable Diffusion
|
# Stable Diffusion
|
||||||
#-----------------
|
#-----------------
|
||||||
SD_WEBUI_URL=http://host.docker.internal:7860
|
SD_WEBUI_URL=http://host.docker.internal:7860
|
||||||
|
|
@ -488,6 +521,8 @@ OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED=
|
||||||
OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE="user.read" # example for Scope Needed for Microsoft Graph API
|
OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE="user.read" # example for Scope Needed for Microsoft Graph API
|
||||||
# Set to true to use the OpenID Connect end session endpoint for logout
|
# Set to true to use the OpenID Connect end session endpoint for logout
|
||||||
OPENID_USE_END_SESSION_ENDPOINT=
|
OPENID_USE_END_SESSION_ENDPOINT=
|
||||||
|
# URL to redirect to after OpenID logout (defaults to ${DOMAIN_CLIENT}/login)
|
||||||
|
OPENID_POST_LOGOUT_REDIRECT_URI=
|
||||||
|
|
||||||
#========================#
|
#========================#
|
||||||
# SharePoint Integration #
|
# SharePoint Integration #
|
||||||
|
|
@ -665,6 +700,9 @@ HELP_AND_FAQ_URL=https://librechat.ai
|
||||||
|
|
||||||
# Enable Redis for caching and session storage
|
# Enable Redis for caching and session storage
|
||||||
# USE_REDIS=true
|
# USE_REDIS=true
|
||||||
|
# Enable Redis for resumable LLM streams (defaults to USE_REDIS value if not set)
|
||||||
|
# Set to false to use in-memory storage for streams while keeping Redis for other caches
|
||||||
|
# USE_REDIS_STREAMS=true
|
||||||
|
|
||||||
# Single Redis instance
|
# Single Redis instance
|
||||||
# REDIS_URI=redis://127.0.0.1:6379
|
# REDIS_URI=redis://127.0.0.1:6379
|
||||||
|
|
|
||||||
4
.github/workflows/backend-review.yml
vendored
4
.github/workflows/backend-review.yml
vendored
|
|
@ -4,6 +4,7 @@ on:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- dev
|
- dev
|
||||||
|
- dev-staging
|
||||||
- release/*
|
- release/*
|
||||||
paths:
|
paths:
|
||||||
- 'api/**'
|
- 'api/**'
|
||||||
|
|
@ -23,6 +24,7 @@ jobs:
|
||||||
BAN_DURATION: ${{ secrets.BAN_DURATION }}
|
BAN_DURATION: ${{ secrets.BAN_DURATION }}
|
||||||
BAN_INTERVAL: ${{ secrets.BAN_INTERVAL }}
|
BAN_INTERVAL: ${{ secrets.BAN_INTERVAL }}
|
||||||
NODE_ENV: CI
|
NODE_ENV: CI
|
||||||
|
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 20.x
|
- name: Use Node.js 20.x
|
||||||
|
|
@ -71,4 +73,4 @@ jobs:
|
||||||
run: cd packages/data-schemas && npm run test:ci
|
run: cd packages/data-schemas && npm run test:ci
|
||||||
|
|
||||||
- name: Run @librechat/api unit tests
|
- name: Run @librechat/api unit tests
|
||||||
run: cd packages/api && npm run test:ci
|
run: cd packages/api && npm run test:ci
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,13 @@ on:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- dev
|
- dev
|
||||||
|
- dev-staging
|
||||||
- release/*
|
- release/*
|
||||||
paths:
|
paths:
|
||||||
- 'packages/api/src/cache/**'
|
- 'packages/api/src/cache/**'
|
||||||
- 'packages/api/src/cluster/**'
|
- 'packages/api/src/cluster/**'
|
||||||
- 'packages/api/src/mcp/**'
|
- 'packages/api/src/mcp/**'
|
||||||
|
- 'packages/api/src/stream/**'
|
||||||
- 'redis-config/**'
|
- 'redis-config/**'
|
||||||
- '.github/workflows/cache-integration-tests.yml'
|
- '.github/workflows/cache-integration-tests.yml'
|
||||||
|
|
||||||
|
|
@ -86,4 +88,4 @@ jobs:
|
||||||
|
|
||||||
- name: Stop Single Redis Instance
|
- name: Stop Single Redis Instance
|
||||||
if: always()
|
if: always()
|
||||||
run: redis-cli -p 6379 shutdown || true
|
run: redis-cli -p 6379 shutdown || true
|
||||||
|
|
|
||||||
14
.github/workflows/client.yml
vendored
14
.github/workflows/client.yml
vendored
|
|
@ -13,9 +13,14 @@ on:
|
||||||
required: false
|
required: false
|
||||||
default: 'Manual publish requested'
|
default: 'Manual publish requested'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write # Required for OIDC trusted publishing
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-publish:
|
build-and-publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
environment: publish # Must match npm trusted publisher config
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|
@ -23,6 +28,10 @@ jobs:
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
|
- name: Update npm for OIDC support
|
||||||
|
run: npm install -g npm@latest # Must be 11.5.1+ for provenance
|
||||||
|
|
||||||
- name: Install client dependencies
|
- name: Install client dependencies
|
||||||
run: cd packages/client && npm ci
|
run: cd packages/client && npm ci
|
||||||
|
|
@ -30,9 +39,6 @@ jobs:
|
||||||
- name: Build client
|
- name: Build client
|
||||||
run: cd packages/client && npm run build
|
run: cd packages/client && npm run build
|
||||||
|
|
||||||
- name: Set up npm authentication
|
|
||||||
run: echo "//registry.npmjs.org/:_authToken=${{ secrets.PUBLISH_NPM_TOKEN }}" > ~/.npmrc
|
|
||||||
|
|
||||||
- name: Check version change
|
- name: Check version change
|
||||||
id: check
|
id: check
|
||||||
working-directory: packages/client
|
working-directory: packages/client
|
||||||
|
|
@ -55,4 +61,4 @@ jobs:
|
||||||
- name: Publish
|
- name: Publish
|
||||||
if: steps.check.outputs.skip != 'true'
|
if: steps.check.outputs.skip != 'true'
|
||||||
working-directory: packages/client
|
working-directory: packages/client
|
||||||
run: npm publish *.tgz --access public
|
run: npm publish *.tgz --access public --provenance
|
||||||
|
|
|
||||||
13
.github/workflows/data-provider.yml
vendored
13
.github/workflows/data-provider.yml
vendored
|
|
@ -13,6 +13,10 @@ on:
|
||||||
required: false
|
required: false
|
||||||
default: 'Manual publish requested'
|
default: 'Manual publish requested'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write # Required for OIDC trusted publishing
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -27,14 +31,17 @@ jobs:
|
||||||
publish-npm:
|
publish-npm:
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
environment: publish # Must match npm trusted publisher config
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
|
- name: Update npm for OIDC support
|
||||||
|
run: npm install -g npm@latest # Must be 11.5.1+ for provenance
|
||||||
|
|
||||||
- run: cd packages/data-provider && npm ci
|
- run: cd packages/data-provider && npm ci
|
||||||
- run: cd packages/data-provider && npm run build
|
- run: cd packages/data-provider && npm run build
|
||||||
- run: cd packages/data-provider && npm publish
|
- run: cd packages/data-provider && npm publish --provenance
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
|
||||||
|
|
|
||||||
14
.github/workflows/data-schemas.yml
vendored
14
.github/workflows/data-schemas.yml
vendored
|
|
@ -13,9 +13,14 @@ on:
|
||||||
required: false
|
required: false
|
||||||
default: 'Manual publish requested'
|
default: 'Manual publish requested'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write # Required for OIDC trusted publishing
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-publish:
|
build-and-publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
environment: publish # Must match npm trusted publisher config
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|
@ -23,6 +28,10 @@ jobs:
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
|
- name: Update npm for OIDC support
|
||||||
|
run: npm install -g npm@latest # Must be 11.5.1+ for provenance
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd packages/data-schemas && npm ci
|
run: cd packages/data-schemas && npm ci
|
||||||
|
|
@ -30,9 +39,6 @@ jobs:
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cd packages/data-schemas && npm run build
|
run: cd packages/data-schemas && npm run build
|
||||||
|
|
||||||
- name: Set up npm authentication
|
|
||||||
run: echo "//registry.npmjs.org/:_authToken=${{ secrets.PUBLISH_NPM_TOKEN }}" > ~/.npmrc
|
|
||||||
|
|
||||||
- name: Check version change
|
- name: Check version change
|
||||||
id: check
|
id: check
|
||||||
working-directory: packages/data-schemas
|
working-directory: packages/data-schemas
|
||||||
|
|
@ -55,4 +61,4 @@ jobs:
|
||||||
- name: Publish
|
- name: Publish
|
||||||
if: steps.check.outputs.skip != 'true'
|
if: steps.check.outputs.skip != 'true'
|
||||||
working-directory: packages/data-schemas
|
working-directory: packages/data-schemas
|
||||||
run: npm publish *.tgz --access public
|
run: npm publish *.tgz --access public --provenance
|
||||||
|
|
|
||||||
66
.github/workflows/dev-staging-images.yml
vendored
Normal file
66
.github/workflows/dev-staging-images.yml
vendored
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
name: Docker Dev Staging Images Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- target: api-build
|
||||||
|
file: Dockerfile.multi
|
||||||
|
image_name: lc-dev-staging-api
|
||||||
|
- target: node
|
||||||
|
file: Dockerfile
|
||||||
|
image_name: lc-dev-staging
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Check out the repository
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Set up QEMU
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
# Set up Docker Buildx
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
# Log in to GitHub Container Registry
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# Login to Docker Hub
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
# Prepare the environment
|
||||||
|
- name: Prepare environment
|
||||||
|
run: |
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Build and push Docker images for each target
|
||||||
|
- name: Build and push Docker images
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ${{ matrix.file }}
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }}
|
||||||
|
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
|
||||||
|
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.sha }}
|
||||||
|
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
target: ${{ matrix.target }}
|
||||||
|
|
||||||
3
.github/workflows/eslint-ci.yml
vendored
3
.github/workflows/eslint-ci.yml
vendored
|
|
@ -5,6 +5,7 @@ on:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- dev
|
- dev
|
||||||
|
- dev-staging
|
||||||
- release/*
|
- release/*
|
||||||
paths:
|
paths:
|
||||||
- 'api/**'
|
- 'api/**'
|
||||||
|
|
@ -56,4 +57,4 @@ jobs:
|
||||||
# Run ESLint
|
# Run ESLint
|
||||||
npx eslint --no-error-on-unmatched-pattern \
|
npx eslint --no-error-on-unmatched-pattern \
|
||||||
--config eslint.config.mjs \
|
--config eslint.config.mjs \
|
||||||
$CHANGED_FILES
|
$CHANGED_FILES
|
||||||
|
|
|
||||||
5
.github/workflows/frontend-review.yml
vendored
5
.github/workflows/frontend-review.yml
vendored
|
|
@ -5,6 +5,7 @@ on:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- dev
|
- dev
|
||||||
|
- dev-staging
|
||||||
- release/*
|
- release/*
|
||||||
paths:
|
paths:
|
||||||
- 'client/**'
|
- 'client/**'
|
||||||
|
|
@ -15,6 +16,8 @@ jobs:
|
||||||
name: Run frontend unit tests on Ubuntu
|
name: Run frontend unit tests on Ubuntu
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 20.x
|
- name: Use Node.js 20.x
|
||||||
|
|
@ -37,6 +40,8 @@ jobs:
|
||||||
name: Run frontend unit tests on Windows
|
name: Run frontend unit tests on Windows
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 20.x
|
- name: Use Node.js 20.x
|
||||||
|
|
|
||||||
83
.github/workflows/unused-packages.yml
vendored
83
.github/workflows/unused-packages.yml
vendored
|
|
@ -8,6 +8,7 @@ on:
|
||||||
- 'client/**'
|
- 'client/**'
|
||||||
- 'api/**'
|
- 'api/**'
|
||||||
- 'packages/client/**'
|
- 'packages/client/**'
|
||||||
|
- 'packages/api/**'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
detect-unused-packages:
|
detect-unused-packages:
|
||||||
|
|
@ -63,35 +64,45 @@ jobs:
|
||||||
extract_deps_from_code() {
|
extract_deps_from_code() {
|
||||||
local folder=$1
|
local folder=$1
|
||||||
local output_file=$2
|
local output_file=$2
|
||||||
|
|
||||||
|
# Initialize empty output file
|
||||||
|
> "$output_file"
|
||||||
|
|
||||||
if [[ -d "$folder" ]]; then
|
if [[ -d "$folder" ]]; then
|
||||||
# Extract require() statements
|
# Extract require() statements (use explicit includes for portability)
|
||||||
grep -rEho "require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)" "$folder" --include=\*.{js,ts,tsx,jsx,mjs,cjs} | \
|
grep -rEho "require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)" "$folder" \
|
||||||
sed -E "s/require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)/\1/" > "$output_file"
|
--include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
|
||||||
|
sed -E "s/require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)/\1/" >> "$output_file" || true
|
||||||
|
|
||||||
# Extract ES6 imports - various patterns
|
# Extract ES6 imports - import x from 'module'
|
||||||
# import x from 'module'
|
grep -rEho "import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
|
||||||
grep -rEho "import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" --include=\*.{js,ts,tsx,jsx,mjs,cjs} | \
|
--include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
|
||||||
sed -E "s/import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file"
|
sed -E "s/import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
|
||||||
|
|
||||||
# import 'module' (side-effect imports)
|
# import 'module' (side-effect imports)
|
||||||
grep -rEho "import ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" --include=\*.{js,ts,tsx,jsx,mjs,cjs} | \
|
grep -rEho "import ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
|
||||||
sed -E "s/import ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file"
|
--include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
|
||||||
|
sed -E "s/import ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
|
||||||
|
|
||||||
# export { x } from 'module' or export * from 'module'
|
# export { x } from 'module' or export * from 'module'
|
||||||
grep -rEho "export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" --include=\*.{js,ts,tsx,jsx,mjs,cjs} | \
|
grep -rEho "export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
|
||||||
sed -E "s/export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file"
|
--include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
|
||||||
|
sed -E "s/export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
|
||||||
|
|
||||||
# import type { x } from 'module' (TypeScript)
|
# import type { x } from 'module' (TypeScript)
|
||||||
grep -rEho "import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" --include=\*.{ts,tsx} | \
|
grep -rEho "import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
|
||||||
sed -E "s/import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file"
|
--include='*.ts' --include='*.tsx' 2>/dev/null | \
|
||||||
|
sed -E "s/import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
|
||||||
|
|
||||||
# Remove subpath imports but keep the base package
|
# Remove subpath imports but keep the base package
|
||||||
# e.g., '@tanstack/react-query/devtools' becomes '@tanstack/react-query'
|
# For scoped packages: '@scope/pkg/subpath' -> '@scope/pkg'
|
||||||
sed -i -E 's|^(@?[a-zA-Z0-9-]+(/[a-zA-Z0-9-]+)?)/.*|\1|' "$output_file"
|
# For regular packages: 'pkg/subpath' -> 'pkg'
|
||||||
|
# Scoped packages (must keep @scope/package, strip anything after)
|
||||||
|
sed -i -E 's|^(@[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/.*|\1|' "$output_file" 2>/dev/null || true
|
||||||
|
# Non-scoped packages (keep package name, strip subpath)
|
||||||
|
sed -i -E 's|^([a-zA-Z0-9_-]+)/.*|\1|' "$output_file" 2>/dev/null || true
|
||||||
|
|
||||||
sort -u "$output_file" -o "$output_file"
|
sort -u "$output_file" -o "$output_file"
|
||||||
else
|
|
||||||
touch "$output_file"
|
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,8 +110,10 @@ jobs:
|
||||||
extract_deps_from_code "client" client_used_code.txt
|
extract_deps_from_code "client" client_used_code.txt
|
||||||
extract_deps_from_code "api" api_used_code.txt
|
extract_deps_from_code "api" api_used_code.txt
|
||||||
|
|
||||||
# Extract dependencies used by @librechat/client package
|
# Extract dependencies used by workspace packages
|
||||||
|
# These packages are used in the workspace but dependencies are provided by parent package.json
|
||||||
extract_deps_from_code "packages/client" packages_client_used_code.txt
|
extract_deps_from_code "packages/client" packages_client_used_code.txt
|
||||||
|
extract_deps_from_code "packages/api" packages_api_used_code.txt
|
||||||
|
|
||||||
- name: Get @librechat/client dependencies
|
- name: Get @librechat/client dependencies
|
||||||
id: get-librechat-client-deps
|
id: get-librechat-client-deps
|
||||||
|
|
@ -126,6 +139,30 @@ jobs:
|
||||||
touch librechat_client_deps.txt
|
touch librechat_client_deps.txt
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Get @librechat/api dependencies
|
||||||
|
id: get-librechat-api-deps
|
||||||
|
run: |
|
||||||
|
if [[ -f "packages/api/package.json" ]]; then
|
||||||
|
# Get all dependencies from @librechat/api (dependencies, devDependencies, and peerDependencies)
|
||||||
|
DEPS=$(jq -r '.dependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "")
|
||||||
|
DEV_DEPS=$(jq -r '.devDependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "")
|
||||||
|
PEER_DEPS=$(jq -r '.peerDependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
# Combine all dependencies
|
||||||
|
echo "$DEPS" > librechat_api_deps.txt
|
||||||
|
echo "$DEV_DEPS" >> librechat_api_deps.txt
|
||||||
|
echo "$PEER_DEPS" >> librechat_api_deps.txt
|
||||||
|
|
||||||
|
# Also include dependencies that are imported in packages/api
|
||||||
|
cat packages_api_used_code.txt >> librechat_api_deps.txt
|
||||||
|
|
||||||
|
# Remove empty lines and sort
|
||||||
|
grep -v '^$' librechat_api_deps.txt | sort -u > temp_deps.txt
|
||||||
|
mv temp_deps.txt librechat_api_deps.txt
|
||||||
|
else
|
||||||
|
touch librechat_api_deps.txt
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Extract Workspace Dependencies
|
- name: Extract Workspace Dependencies
|
||||||
id: extract-workspace-deps
|
id: extract-workspace-deps
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -184,8 +221,8 @@ jobs:
|
||||||
chmod -R 755 client
|
chmod -R 755 client
|
||||||
cd client
|
cd client
|
||||||
UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
|
UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
|
||||||
# Exclude dependencies used in scripts, code, and workspace packages
|
# Exclude dependencies used in scripts, code, workspace packages, and @librechat/client imports
|
||||||
UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../client_used_deps.txt ../client_used_code.txt ../client_workspace_deps.txt | sort) || echo "")
|
UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../client_used_deps.txt ../client_used_code.txt ../client_workspace_deps.txt ../packages_client_used_code.txt ../librechat_client_deps.txt 2>/dev/null | sort -u) || echo "")
|
||||||
# Filter out false positives
|
# Filter out false positives
|
||||||
UNUSED=$(echo "$UNUSED" | grep -v "^micromark-extension-llm-math$" || echo "")
|
UNUSED=$(echo "$UNUSED" | grep -v "^micromark-extension-llm-math$" || echo "")
|
||||||
echo "CLIENT_UNUSED<<EOF" >> $GITHUB_ENV
|
echo "CLIENT_UNUSED<<EOF" >> $GITHUB_ENV
|
||||||
|
|
@ -201,8 +238,8 @@ jobs:
|
||||||
chmod -R 755 api
|
chmod -R 755 api
|
||||||
cd api
|
cd api
|
||||||
UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
|
UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
|
||||||
# Exclude dependencies used in scripts, code, and workspace packages
|
# Exclude dependencies used in scripts, code, workspace packages, and @librechat/api imports
|
||||||
UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../api_used_deps.txt ../api_used_code.txt ../api_workspace_deps.txt | sort) || echo "")
|
UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../api_used_deps.txt ../api_used_code.txt ../api_workspace_deps.txt ../packages_api_used_code.txt ../librechat_api_deps.txt 2>/dev/null | sort -u) || echo "")
|
||||||
echo "API_UNUSED<<EOF" >> $GITHUB_ENV
|
echo "API_UNUSED<<EOF" >> $GITHUB_ENV
|
||||||
echo "$UNUSED" >> $GITHUB_ENV
|
echo "$UNUSED" >> $GITHUB_ENV
|
||||||
echo "EOF" >> $GITHUB_ENV
|
echo "EOF" >> $GITHUB_ENV
|
||||||
|
|
@ -241,4 +278,4 @@ jobs:
|
||||||
|
|
||||||
- name: Fail workflow if unused dependencies found
|
- name: Fail workflow if unused dependencies found
|
||||||
if: env.ROOT_UNUSED != '' || env.CLIENT_UNUSED != '' || env.API_UNUSED != ''
|
if: env.ROOT_UNUSED != '' || env.CLIENT_UNUSED != '' || env.API_UNUSED != ''
|
||||||
run: exit 1
|
run: exit 1
|
||||||
|
|
|
||||||
13
Dockerfile
13
Dockerfile
|
|
@ -1,4 +1,4 @@
|
||||||
# v0.8.1-rc2
|
# v0.8.2-rc3
|
||||||
|
|
||||||
# Base node image
|
# Base node image
|
||||||
FROM node:20-alpine AS node
|
FROM node:20-alpine AS node
|
||||||
|
|
@ -11,9 +11,12 @@ RUN apk add --no-cache python3 py3-pip uv
|
||||||
ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2
|
ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2
|
||||||
|
|
||||||
# Add `uv` for extended MCP support
|
# Add `uv` for extended MCP support
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.6.13 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.9.5-python3.12-alpine /usr/local/bin/uv /usr/local/bin/uvx /bin/
|
||||||
RUN uv --version
|
RUN uv --version
|
||||||
|
|
||||||
|
# Set configurable max-old-space-size with default
|
||||||
|
ARG NODE_MAX_OLD_SPACE_SIZE=6144
|
||||||
|
|
||||||
RUN mkdir -p /app && chown node:node /app
|
RUN mkdir -p /app && chown node:node /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
@ -30,7 +33,7 @@ RUN \
|
||||||
# Allow mounting of these files, which have no default
|
# Allow mounting of these files, which have no default
|
||||||
touch .env ; \
|
touch .env ; \
|
||||||
# Create directories for the volumes to inherit the correct permissions
|
# Create directories for the volumes to inherit the correct permissions
|
||||||
mkdir -p /app/client/public/images /app/api/logs /app/uploads ; \
|
mkdir -p /app/client/public/images /app/logs /app/uploads ; \
|
||||||
npm config set fetch-retry-maxtimeout 600000 ; \
|
npm config set fetch-retry-maxtimeout 600000 ; \
|
||||||
npm config set fetch-retries 5 ; \
|
npm config set fetch-retries 5 ; \
|
||||||
npm config set fetch-retry-mintimeout 15000 ; \
|
npm config set fetch-retry-mintimeout 15000 ; \
|
||||||
|
|
@ -39,8 +42,8 @@ RUN \
|
||||||
COPY --chown=node:node . .
|
COPY --chown=node:node . .
|
||||||
|
|
||||||
RUN \
|
RUN \
|
||||||
# React client build
|
# React client build with configurable memory
|
||||||
NODE_OPTIONS="--max-old-space-size=2048" npm run frontend; \
|
NODE_OPTIONS="--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE}" npm run frontend; \
|
||||||
npm prune --production; \
|
npm prune --production; \
|
||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
# Dockerfile.multi
|
# Dockerfile.multi
|
||||||
# v0.8.1-rc2
|
# v0.8.2-rc3
|
||||||
|
|
||||||
|
# Set configurable max-old-space-size with default
|
||||||
|
ARG NODE_MAX_OLD_SPACE_SIZE=6144
|
||||||
|
|
||||||
# Base for all builds
|
# Base for all builds
|
||||||
FROM node:20-alpine AS base-min
|
FROM node:20-alpine AS base-min
|
||||||
|
|
@ -7,6 +10,7 @@ FROM node:20-alpine AS base-min
|
||||||
RUN apk add --no-cache jemalloc
|
RUN apk add --no-cache jemalloc
|
||||||
# Set environment variable to use jemalloc
|
# Set environment variable to use jemalloc
|
||||||
ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2
|
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 && \
|
||||||
|
|
@ -59,7 +63,8 @@ COPY client ./
|
||||||
COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist
|
COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist
|
||||||
COPY --from=client-package-build /app/packages/client/dist /app/packages/client/dist
|
COPY --from=client-package-build /app/packages/client/dist /app/packages/client/dist
|
||||||
COPY --from=client-package-build /app/packages/client/src /app/packages/client/src
|
COPY --from=client-package-build /app/packages/client/src /app/packages/client/src
|
||||||
ENV NODE_OPTIONS="--max-old-space-size=2048"
|
ARG NODE_MAX_OLD_SPACE_SIZE
|
||||||
|
ENV NODE_OPTIONS="--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE}"
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# API setup (including client dist)
|
# API setup (including client dist)
|
||||||
|
|
@ -79,4 +84,4 @@ COPY --from=client-build /app/client/dist ./client/dist
|
||||||
WORKDIR /app/api
|
WORKDIR /app/api
|
||||||
EXPOSE 3080
|
EXPOSE 3080
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
CMD ["node", "server/index.js"]
|
CMD ["node", "server/index.js"]
|
||||||
|
|
|
||||||
|
|
@ -1,991 +0,0 @@
|
||||||
const Anthropic = require('@anthropic-ai/sdk');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
|
||||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
|
||||||
const {
|
|
||||||
Constants,
|
|
||||||
ErrorTypes,
|
|
||||||
EModelEndpoint,
|
|
||||||
parseTextParts,
|
|
||||||
anthropicSettings,
|
|
||||||
getResponseSender,
|
|
||||||
validateVisionModel,
|
|
||||||
} = require('librechat-data-provider');
|
|
||||||
const { sleep, SplitStreamHandler: _Handler, addCacheControl } = require('@librechat/agents');
|
|
||||||
const {
|
|
||||||
Tokenizer,
|
|
||||||
createFetch,
|
|
||||||
matchModelName,
|
|
||||||
getClaudeHeaders,
|
|
||||||
getModelMaxTokens,
|
|
||||||
configureReasoning,
|
|
||||||
checkPromptCacheSupport,
|
|
||||||
getModelMaxOutputTokens,
|
|
||||||
createStreamEventHandlers,
|
|
||||||
} = require('@librechat/api');
|
|
||||||
const {
|
|
||||||
truncateText,
|
|
||||||
formatMessage,
|
|
||||||
titleFunctionPrompt,
|
|
||||||
parseParamFromPrompt,
|
|
||||||
createContextHandlers,
|
|
||||||
} = require('./prompts');
|
|
||||||
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
|
||||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
|
||||||
const BaseClient = require('./BaseClient');
|
|
||||||
|
|
||||||
const HUMAN_PROMPT = '\n\nHuman:';
|
|
||||||
const AI_PROMPT = '\n\nAssistant:';
|
|
||||||
|
|
||||||
class SplitStreamHandler extends _Handler {
|
|
||||||
getDeltaContent(chunk) {
|
|
||||||
return (chunk?.delta?.text ?? chunk?.completion) || '';
|
|
||||||
}
|
|
||||||
getReasoningDelta(chunk) {
|
|
||||||
return chunk?.delta?.thinking || '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Helper function to introduce a delay before retrying */
|
|
||||||
function delayBeforeRetry(attempts, baseDelay = 1000) {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, baseDelay * attempts));
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenEventTypes = new Set(['message_start', 'message_delta']);
|
|
||||||
const { legacy } = anthropicSettings;
|
|
||||||
|
|
||||||
class AnthropicClient extends BaseClient {
|
|
||||||
constructor(apiKey, options = {}) {
|
|
||||||
super(apiKey, options);
|
|
||||||
this.apiKey = apiKey || process.env.ANTHROPIC_API_KEY;
|
|
||||||
this.userLabel = HUMAN_PROMPT;
|
|
||||||
this.assistantLabel = AI_PROMPT;
|
|
||||||
this.contextStrategy = options.contextStrategy
|
|
||||||
? options.contextStrategy.toLowerCase()
|
|
||||||
: 'discard';
|
|
||||||
this.setOptions(options);
|
|
||||||
/** @type {string | undefined} */
|
|
||||||
this.systemMessage;
|
|
||||||
/** @type {AnthropicMessageStartEvent| undefined} */
|
|
||||||
this.message_start;
|
|
||||||
/** @type {AnthropicMessageDeltaEvent| undefined} */
|
|
||||||
this.message_delta;
|
|
||||||
/** Whether the model is part of the Claude 3 Family
|
|
||||||
* @type {boolean} */
|
|
||||||
this.isClaudeLatest;
|
|
||||||
/** Whether to use Messages API or Completions API
|
|
||||||
* @type {boolean} */
|
|
||||||
this.useMessages;
|
|
||||||
/** Whether or not the model supports Prompt Caching
|
|
||||||
* @type {boolean} */
|
|
||||||
this.supportsCacheControl;
|
|
||||||
/** The key for the usage object's input tokens
|
|
||||||
* @type {string} */
|
|
||||||
this.inputTokensKey = 'input_tokens';
|
|
||||||
/** The key for the usage object's output tokens
|
|
||||||
* @type {string} */
|
|
||||||
this.outputTokensKey = 'output_tokens';
|
|
||||||
/** @type {SplitStreamHandler | undefined} */
|
|
||||||
this.streamHandler;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOptions(options) {
|
|
||||||
if (this.options && !this.options.replaceOptions) {
|
|
||||||
// nested options aren't spread properly, so we need to do this manually
|
|
||||||
this.options.modelOptions = {
|
|
||||||
...this.options.modelOptions,
|
|
||||||
...options.modelOptions,
|
|
||||||
};
|
|
||||||
delete options.modelOptions;
|
|
||||||
// now we can merge options
|
|
||||||
this.options = {
|
|
||||||
...this.options,
|
|
||||||
...options,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
this.options = options;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.modelOptions = Object.assign(
|
|
||||||
{
|
|
||||||
model: anthropicSettings.model.default,
|
|
||||||
},
|
|
||||||
this.modelOptions,
|
|
||||||
this.options.modelOptions,
|
|
||||||
);
|
|
||||||
|
|
||||||
const modelMatch = matchModelName(this.modelOptions.model, EModelEndpoint.anthropic);
|
|
||||||
this.isClaudeLatest =
|
|
||||||
/claude-[3-9]/.test(modelMatch) || /claude-(?:sonnet|opus|haiku)-[4-9]/.test(modelMatch);
|
|
||||||
const isLegacyOutput = !(
|
|
||||||
/claude-3[-.]5-sonnet/.test(modelMatch) ||
|
|
||||||
/claude-3[-.]7/.test(modelMatch) ||
|
|
||||||
/claude-(?:sonnet|opus|haiku)-[4-9]/.test(modelMatch) ||
|
|
||||||
/claude-[4-9]/.test(modelMatch)
|
|
||||||
);
|
|
||||||
this.supportsCacheControl = this.options.promptCache && checkPromptCacheSupport(modelMatch);
|
|
||||||
|
|
||||||
if (
|
|
||||||
isLegacyOutput &&
|
|
||||||
this.modelOptions.maxOutputTokens &&
|
|
||||||
this.modelOptions.maxOutputTokens > legacy.maxOutputTokens.default
|
|
||||||
) {
|
|
||||||
this.modelOptions.maxOutputTokens = legacy.maxOutputTokens.default;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.useMessages = this.isClaudeLatest || !!this.options.attachments;
|
|
||||||
|
|
||||||
this.defaultVisionModel = this.options.visionModel ?? 'claude-3-sonnet-20240229';
|
|
||||||
this.options.attachments?.then((attachments) => this.checkVisionRequest(attachments));
|
|
||||||
|
|
||||||
this.maxContextTokens =
|
|
||||||
this.options.maxContextTokens ??
|
|
||||||
getModelMaxTokens(this.modelOptions.model, EModelEndpoint.anthropic) ??
|
|
||||||
100000;
|
|
||||||
this.maxResponseTokens =
|
|
||||||
this.modelOptions.maxOutputTokens ??
|
|
||||||
getModelMaxOutputTokens(
|
|
||||||
this.modelOptions.model,
|
|
||||||
this.options.endpointType ?? this.options.endpoint,
|
|
||||||
this.options.endpointTokenConfig,
|
|
||||||
) ??
|
|
||||||
anthropicSettings.maxOutputTokens.reset(this.modelOptions.model);
|
|
||||||
this.maxPromptTokens =
|
|
||||||
this.options.maxPromptTokens || this.maxContextTokens - this.maxResponseTokens;
|
|
||||||
|
|
||||||
const reservedTokens = this.maxPromptTokens + this.maxResponseTokens;
|
|
||||||
if (reservedTokens > this.maxContextTokens) {
|
|
||||||
const info = `Total Possible Tokens + Max Output Tokens must be less than or equal to Max Context Tokens: ${this.maxPromptTokens} (total possible output) + ${this.maxResponseTokens} (max output) = ${reservedTokens}/${this.maxContextTokens} (max context)`;
|
|
||||||
const errorMessage = `{ "type": "${ErrorTypes.INPUT_LENGTH}", "info": "${info}" }`;
|
|
||||||
logger.warn(info);
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
} else if (this.maxResponseTokens === this.maxContextTokens) {
|
|
||||||
const info = `Max Output Tokens must be less than Max Context Tokens: ${this.maxResponseTokens} (max output) = ${this.maxContextTokens} (max context)`;
|
|
||||||
const errorMessage = `{ "type": "${ErrorTypes.INPUT_LENGTH}", "info": "${info}" }`;
|
|
||||||
logger.warn(info);
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sender =
|
|
||||||
this.options.sender ??
|
|
||||||
getResponseSender({
|
|
||||||
model: this.modelOptions.model,
|
|
||||||
endpoint: EModelEndpoint.anthropic,
|
|
||||||
modelLabel: this.options.modelLabel,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.startToken = '||>';
|
|
||||||
this.endToken = '';
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the initialized Anthropic client.
|
|
||||||
* @param {Partial<Anthropic.ClientOptions>} requestOptions - The options for the client.
|
|
||||||
* @returns {Anthropic} The Anthropic client instance.
|
|
||||||
*/
|
|
||||||
getClient(requestOptions) {
|
|
||||||
/** @type {Anthropic.ClientOptions} */
|
|
||||||
const options = {
|
|
||||||
fetch: createFetch({
|
|
||||||
directEndpoint: this.options.directEndpoint,
|
|
||||||
reverseProxyUrl: this.options.reverseProxyUrl,
|
|
||||||
}),
|
|
||||||
apiKey: this.apiKey,
|
|
||||||
fetchOptions: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.options.proxy) {
|
|
||||||
options.fetchOptions.agent = new HttpsProxyAgent(this.options.proxy);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.options.reverseProxyUrl) {
|
|
||||||
options.baseURL = this.options.reverseProxyUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = getClaudeHeaders(requestOptions?.model, this.supportsCacheControl);
|
|
||||||
if (headers) {
|
|
||||||
options.defaultHeaders = headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Anthropic(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get stream usage as returned by this client's API response.
|
|
||||||
* @returns {AnthropicStreamUsage} The stream usage object.
|
|
||||||
*/
|
|
||||||
getStreamUsage() {
|
|
||||||
const inputUsage = this.message_start?.message?.usage ?? {};
|
|
||||||
const outputUsage = this.message_delta?.usage ?? {};
|
|
||||||
return Object.assign({}, inputUsage, outputUsage);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates the correct token count for the current user message based on the token count map and API usage.
|
|
||||||
* Edge case: If the calculation results in a negative value, it returns the original estimate.
|
|
||||||
* If revisiting a conversation with a chat history entirely composed of token estimates,
|
|
||||||
* the cumulative token count going forward should become more accurate as the conversation progresses.
|
|
||||||
* @param {Object} params - The parameters for the calculation.
|
|
||||||
* @param {Record<string, number>} params.tokenCountMap - A map of message IDs to their token counts.
|
|
||||||
* @param {string} params.currentMessageId - The ID of the current message to calculate.
|
|
||||||
* @param {AnthropicStreamUsage} params.usage - The usage object returned by the API.
|
|
||||||
* @returns {number} The correct token count for the current user message.
|
|
||||||
*/
|
|
||||||
calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage }) {
|
|
||||||
const originalEstimate = tokenCountMap[currentMessageId] || 0;
|
|
||||||
|
|
||||||
if (!usage || typeof usage.input_tokens !== 'number') {
|
|
||||||
return originalEstimate;
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenCountMap[currentMessageId] = 0;
|
|
||||||
const totalTokensFromMap = Object.values(tokenCountMap).reduce((sum, count) => {
|
|
||||||
const numCount = Number(count);
|
|
||||||
return sum + (isNaN(numCount) ? 0 : numCount);
|
|
||||||
}, 0);
|
|
||||||
const totalInputTokens =
|
|
||||||
(usage.input_tokens ?? 0) +
|
|
||||||
(usage.cache_creation_input_tokens ?? 0) +
|
|
||||||
(usage.cache_read_input_tokens ?? 0);
|
|
||||||
|
|
||||||
const currentMessageTokens = totalInputTokens - totalTokensFromMap;
|
|
||||||
return currentMessageTokens > 0 ? currentMessageTokens : originalEstimate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Token Count for LibreChat Message
|
|
||||||
* @param {TMessage} responseMessage
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
|
||||||
getTokenCountForResponse(responseMessage) {
|
|
||||||
return this.getTokenCountForMessage({
|
|
||||||
role: 'assistant',
|
|
||||||
content: responseMessage.text,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* Checks if the model is a vision model based on request attachments and sets the appropriate options:
|
|
||||||
* - Sets `this.modelOptions.model` to `gpt-4-vision-preview` if the request is a vision request.
|
|
||||||
* - Sets `this.isVisionModel` to `true` if vision request.
|
|
||||||
* - Deletes `this.modelOptions.stop` if vision request.
|
|
||||||
* @param {MongoFile[]} attachments
|
|
||||||
*/
|
|
||||||
checkVisionRequest(attachments) {
|
|
||||||
const availableModels = this.options.modelsConfig?.[EModelEndpoint.anthropic];
|
|
||||||
this.isVisionModel = validateVisionModel({ model: this.modelOptions.model, availableModels });
|
|
||||||
|
|
||||||
const visionModelAvailable = availableModels?.includes(this.defaultVisionModel);
|
|
||||||
if (
|
|
||||||
attachments &&
|
|
||||||
attachments.some((file) => file?.type && file?.type?.includes('image')) &&
|
|
||||||
visionModelAvailable &&
|
|
||||||
!this.isVisionModel
|
|
||||||
) {
|
|
||||||
this.modelOptions.model = this.defaultVisionModel;
|
|
||||||
this.isVisionModel = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate the token cost in tokens for an image based on its dimensions and detail level.
|
|
||||||
*
|
|
||||||
* For reference, see: https://docs.anthropic.com/claude/docs/vision#image-costs
|
|
||||||
*
|
|
||||||
* @param {Object} image - The image object.
|
|
||||||
* @param {number} image.width - The width of the image.
|
|
||||||
* @param {number} image.height - The height of the image.
|
|
||||||
* @returns {number} The calculated token cost measured by tokens.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
calculateImageTokenCost({ width, height }) {
|
|
||||||
return Math.ceil((width * height) / 750);
|
|
||||||
}
|
|
||||||
|
|
||||||
async addImageURLs(message, attachments) {
|
|
||||||
const { files, image_urls } = await encodeAndFormat(this.options.req, attachments, {
|
|
||||||
endpoint: EModelEndpoint.anthropic,
|
|
||||||
});
|
|
||||||
message.image_urls = image_urls.length ? image_urls : undefined;
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {object} params
|
|
||||||
* @param {number} params.promptTokens
|
|
||||||
* @param {number} params.completionTokens
|
|
||||||
* @param {AnthropicStreamUsage} [params.usage]
|
|
||||||
* @param {string} [params.model]
|
|
||||||
* @param {string} [params.context='message']
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async recordTokenUsage({ promptTokens, completionTokens, usage, model, context = 'message' }) {
|
|
||||||
if (usage != null && usage?.input_tokens != null) {
|
|
||||||
const input = usage.input_tokens ?? 0;
|
|
||||||
const write = usage.cache_creation_input_tokens ?? 0;
|
|
||||||
const read = usage.cache_read_input_tokens ?? 0;
|
|
||||||
|
|
||||||
await spendStructuredTokens(
|
|
||||||
{
|
|
||||||
context,
|
|
||||||
user: this.user,
|
|
||||||
conversationId: this.conversationId,
|
|
||||||
model: model ?? this.modelOptions.model,
|
|
||||||
endpointTokenConfig: this.options.endpointTokenConfig,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
promptTokens: { input, write, read },
|
|
||||||
completionTokens,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await spendTokens(
|
|
||||||
{
|
|
||||||
context,
|
|
||||||
user: this.user,
|
|
||||||
conversationId: this.conversationId,
|
|
||||||
model: model ?? this.modelOptions.model,
|
|
||||||
endpointTokenConfig: this.options.endpointTokenConfig,
|
|
||||||
},
|
|
||||||
{ promptTokens, completionTokens },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async buildMessages(messages, parentMessageId) {
|
|
||||||
const orderedMessages = this.constructor.getMessagesForConversation({
|
|
||||||
messages,
|
|
||||||
parentMessageId,
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('[AnthropicClient] orderedMessages', { orderedMessages, parentMessageId });
|
|
||||||
|
|
||||||
if (this.options.attachments) {
|
|
||||||
const attachments = await this.options.attachments;
|
|
||||||
const images = attachments.filter((file) => file.type.includes('image'));
|
|
||||||
|
|
||||||
if (images.length && !this.isVisionModel) {
|
|
||||||
throw new Error('Images are only supported with the Claude 3 family of models');
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestMessage = orderedMessages[orderedMessages.length - 1];
|
|
||||||
|
|
||||||
if (this.message_file_map) {
|
|
||||||
this.message_file_map[latestMessage.messageId] = attachments;
|
|
||||||
} else {
|
|
||||||
this.message_file_map = {
|
|
||||||
[latestMessage.messageId]: attachments,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = await this.addImageURLs(latestMessage, attachments);
|
|
||||||
|
|
||||||
this.options.attachments = files;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.message_file_map) {
|
|
||||||
this.contextHandlers = createContextHandlers(
|
|
||||||
this.options.req,
|
|
||||||
orderedMessages[orderedMessages.length - 1].text,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const formattedMessages = orderedMessages.map((message, i) => {
|
|
||||||
const formattedMessage = this.useMessages
|
|
||||||
? formatMessage({
|
|
||||||
message,
|
|
||||||
endpoint: EModelEndpoint.anthropic,
|
|
||||||
})
|
|
||||||
: {
|
|
||||||
author: message.isCreatedByUser ? this.userLabel : this.assistantLabel,
|
|
||||||
content: message?.content ?? message.text,
|
|
||||||
};
|
|
||||||
|
|
||||||
const needsTokenCount = this.contextStrategy && !orderedMessages[i].tokenCount;
|
|
||||||
/* If tokens were never counted, or, is a Vision request and the message has files, count again */
|
|
||||||
if (needsTokenCount || (this.isVisionModel && (message.image_urls || message.files))) {
|
|
||||||
orderedMessages[i].tokenCount = this.getTokenCountForMessage(formattedMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* If message has files, calculate image token cost */
|
|
||||||
if (this.message_file_map && this.message_file_map[message.messageId]) {
|
|
||||||
const attachments = this.message_file_map[message.messageId];
|
|
||||||
for (const file of attachments) {
|
|
||||||
if (file.embedded) {
|
|
||||||
this.contextHandlers?.processFile(file);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (file.metadata?.fileIdentifier) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
orderedMessages[i].tokenCount += this.calculateImageTokenCost({
|
|
||||||
width: file.width,
|
|
||||||
height: file.height,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formattedMessage.tokenCount = orderedMessages[i].tokenCount;
|
|
||||||
return formattedMessage;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.contextHandlers) {
|
|
||||||
this.augmentedPrompt = await this.contextHandlers.createContext();
|
|
||||||
this.options.promptPrefix = this.augmentedPrompt + (this.options.promptPrefix ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
let { context: messagesInWindow, remainingContextTokens } =
|
|
||||||
await this.getMessagesWithinTokenLimit({ messages: formattedMessages });
|
|
||||||
|
|
||||||
const tokenCountMap = orderedMessages
|
|
||||||
.slice(orderedMessages.length - messagesInWindow.length)
|
|
||||||
.reduce((map, message, index) => {
|
|
||||||
const { messageId } = message;
|
|
||||||
if (!messageId) {
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
map[messageId] = orderedMessages[index].tokenCount;
|
|
||||||
return map;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
logger.debug('[AnthropicClient]', {
|
|
||||||
messagesInWindow: messagesInWindow.length,
|
|
||||||
remainingContextTokens,
|
|
||||||
});
|
|
||||||
|
|
||||||
let lastAuthor = '';
|
|
||||||
let groupedMessages = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < messagesInWindow.length; i++) {
|
|
||||||
const message = messagesInWindow[i];
|
|
||||||
const author = message.role ?? message.author;
|
|
||||||
// If last author is not same as current author, add to new group
|
|
||||||
if (lastAuthor !== author) {
|
|
||||||
const newMessage = {
|
|
||||||
content: [message.content],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (message.role) {
|
|
||||||
newMessage.role = message.role;
|
|
||||||
} else {
|
|
||||||
newMessage.author = message.author;
|
|
||||||
}
|
|
||||||
|
|
||||||
groupedMessages.push(newMessage);
|
|
||||||
lastAuthor = author;
|
|
||||||
// If same author, append content to the last group
|
|
||||||
} else {
|
|
||||||
groupedMessages[groupedMessages.length - 1].content.push(message.content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
groupedMessages = groupedMessages.map((msg, i) => {
|
|
||||||
const isLast = i === groupedMessages.length - 1;
|
|
||||||
if (msg.content.length === 1) {
|
|
||||||
const content = msg.content[0];
|
|
||||||
return {
|
|
||||||
...msg,
|
|
||||||
// reason: final assistant content cannot end with trailing whitespace
|
|
||||||
content:
|
|
||||||
isLast && this.useMessages && msg.role === 'assistant' && typeof content === 'string'
|
|
||||||
? content?.trim()
|
|
||||||
: content,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.useMessages && msg.tokenCount) {
|
|
||||||
delete msg.tokenCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
return msg;
|
|
||||||
});
|
|
||||||
|
|
||||||
let identityPrefix = '';
|
|
||||||
if (this.options.userLabel) {
|
|
||||||
identityPrefix = `\nHuman's name: ${this.options.userLabel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.options.modelLabel) {
|
|
||||||
identityPrefix = `${identityPrefix}\nYou are ${this.options.modelLabel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let promptPrefix = (this.options.promptPrefix ?? '').trim();
|
|
||||||
if (typeof this.options.artifactsPrompt === 'string' && this.options.artifactsPrompt) {
|
|
||||||
promptPrefix = `${promptPrefix ?? ''}\n${this.options.artifactsPrompt}`.trim();
|
|
||||||
}
|
|
||||||
if (promptPrefix) {
|
|
||||||
// If the prompt prefix doesn't end with the end token, add it.
|
|
||||||
if (!promptPrefix.endsWith(`${this.endToken}`)) {
|
|
||||||
promptPrefix = `${promptPrefix.trim()}${this.endToken}\n\n`;
|
|
||||||
}
|
|
||||||
promptPrefix = `\nContext:\n${promptPrefix}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (identityPrefix) {
|
|
||||||
promptPrefix = `${identityPrefix}${promptPrefix}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prompt AI to respond, empty if last message was from AI
|
|
||||||
let isEdited = lastAuthor === this.assistantLabel;
|
|
||||||
const promptSuffix = isEdited ? '' : `${promptPrefix}${this.assistantLabel}\n`;
|
|
||||||
let currentTokenCount =
|
|
||||||
isEdited || this.useMessages
|
|
||||||
? this.getTokenCount(promptPrefix)
|
|
||||||
: this.getTokenCount(promptSuffix);
|
|
||||||
|
|
||||||
let promptBody = '';
|
|
||||||
const maxTokenCount = this.maxPromptTokens;
|
|
||||||
|
|
||||||
const context = [];
|
|
||||||
|
|
||||||
// Iterate backwards through the messages, adding them to the prompt until we reach the max token count.
|
|
||||||
// Do this within a recursive async function so that it doesn't block the event loop for too long.
|
|
||||||
// Also, remove the next message when the message that puts us over the token limit is created by the user.
|
|
||||||
// Otherwise, remove only the exceeding message. This is due to Anthropic's strict payload rule to start with "Human:".
|
|
||||||
const nextMessage = {
|
|
||||||
remove: false,
|
|
||||||
tokenCount: 0,
|
|
||||||
messageString: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildPromptBody = async () => {
|
|
||||||
if (currentTokenCount < maxTokenCount && groupedMessages.length > 0) {
|
|
||||||
const message = groupedMessages.pop();
|
|
||||||
const isCreatedByUser = message.author === this.userLabel;
|
|
||||||
// Use promptPrefix if message is edited assistant'
|
|
||||||
const messagePrefix =
|
|
||||||
isCreatedByUser || !isEdited ? message.author : `${promptPrefix}${message.author}`;
|
|
||||||
const messageString = `${messagePrefix}\n${message.content}${this.endToken}\n`;
|
|
||||||
let newPromptBody = `${messageString}${promptBody}`;
|
|
||||||
|
|
||||||
context.unshift(message);
|
|
||||||
|
|
||||||
const tokenCountForMessage = this.getTokenCount(messageString);
|
|
||||||
const newTokenCount = currentTokenCount + tokenCountForMessage;
|
|
||||||
|
|
||||||
if (!isCreatedByUser) {
|
|
||||||
nextMessage.messageString = messageString;
|
|
||||||
nextMessage.tokenCount = tokenCountForMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newTokenCount > maxTokenCount) {
|
|
||||||
if (!promptBody) {
|
|
||||||
// This is the first message, so we can't add it. Just throw an error.
|
|
||||||
throw new Error(
|
|
||||||
`Prompt is too long. Max token count is ${maxTokenCount}, but prompt is ${newTokenCount} tokens long.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, ths message would put us over the token limit, so don't add it.
|
|
||||||
// if created by user, remove next message, otherwise remove only this message
|
|
||||||
if (isCreatedByUser) {
|
|
||||||
nextMessage.remove = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
promptBody = newPromptBody;
|
|
||||||
currentTokenCount = newTokenCount;
|
|
||||||
|
|
||||||
// Switch off isEdited after using it for the first time
|
|
||||||
if (isEdited) {
|
|
||||||
isEdited = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// wait for next tick to avoid blocking the event loop
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
return buildPromptBody();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const messagesPayload = [];
|
|
||||||
const buildMessagesPayload = async () => {
|
|
||||||
let canContinue = true;
|
|
||||||
|
|
||||||
if (promptPrefix) {
|
|
||||||
this.systemMessage = promptPrefix;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (currentTokenCount < maxTokenCount && groupedMessages.length > 0 && canContinue) {
|
|
||||||
const message = groupedMessages.pop();
|
|
||||||
|
|
||||||
let tokenCountForMessage = message.tokenCount ?? this.getTokenCountForMessage(message);
|
|
||||||
|
|
||||||
const newTokenCount = currentTokenCount + tokenCountForMessage;
|
|
||||||
const exceededMaxCount = newTokenCount > maxTokenCount;
|
|
||||||
|
|
||||||
if (exceededMaxCount && messagesPayload.length === 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Prompt is too long. Max token count is ${maxTokenCount}, but prompt is ${newTokenCount} tokens long.`,
|
|
||||||
);
|
|
||||||
} else if (exceededMaxCount) {
|
|
||||||
canContinue = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
delete message.tokenCount;
|
|
||||||
messagesPayload.unshift(message);
|
|
||||||
currentTokenCount = newTokenCount;
|
|
||||||
|
|
||||||
// Switch off isEdited after using it once
|
|
||||||
if (isEdited && message.role === 'assistant') {
|
|
||||||
isEdited = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for next tick to avoid blocking the event loop
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const processTokens = () => {
|
|
||||||
// Add 2 tokens for metadata after all messages have been counted.
|
|
||||||
currentTokenCount += 2;
|
|
||||||
|
|
||||||
// Use up to `this.maxContextTokens` tokens (prompt + response), but try to leave `this.maxTokens` tokens for the response.
|
|
||||||
this.modelOptions.maxOutputTokens = Math.min(
|
|
||||||
this.maxContextTokens - currentTokenCount,
|
|
||||||
this.maxResponseTokens,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (
|
|
||||||
/claude-[3-9]/.test(this.modelOptions.model) ||
|
|
||||||
/claude-(?:sonnet|opus|haiku)-[4-9]/.test(this.modelOptions.model)
|
|
||||||
) {
|
|
||||||
await buildMessagesPayload();
|
|
||||||
processTokens();
|
|
||||||
return {
|
|
||||||
prompt: messagesPayload,
|
|
||||||
context: messagesInWindow,
|
|
||||||
promptTokens: currentTokenCount,
|
|
||||||
tokenCountMap,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
await buildPromptBody();
|
|
||||||
processTokens();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextMessage.remove) {
|
|
||||||
promptBody = promptBody.replace(nextMessage.messageString, '');
|
|
||||||
currentTokenCount -= nextMessage.tokenCount;
|
|
||||||
context.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
let prompt = `${promptBody}${promptSuffix}`;
|
|
||||||
|
|
||||||
return { prompt, context, promptTokens: currentTokenCount, tokenCountMap };
|
|
||||||
}
|
|
||||||
|
|
||||||
getCompletion() {
|
|
||||||
logger.debug("AnthropicClient doesn't use getCompletion (all handled in sendCompletion)");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a message or completion response using the Anthropic client.
|
|
||||||
* @param {Anthropic} client - The Anthropic client instance.
|
|
||||||
* @param {Anthropic.default.MessageCreateParams | Anthropic.default.CompletionCreateParams} options - The options for the message or completion.
|
|
||||||
* @param {boolean} useMessages - Whether to use messages or completions. Defaults to `this.useMessages`.
|
|
||||||
* @returns {Promise<Anthropic.default.Message | Anthropic.default.Completion>} The response from the Anthropic client.
|
|
||||||
*/
|
|
||||||
async createResponse(client, options, useMessages) {
|
|
||||||
return (useMessages ?? this.useMessages)
|
|
||||||
? await client.messages.create(options)
|
|
||||||
: await client.completions.create(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string[]} [intermediateReply]
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
getStreamText(intermediateReply) {
|
|
||||||
if (!this.streamHandler) {
|
|
||||||
return intermediateReply?.join('') ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const reasoningText = this.streamHandler.reasoningTokens.join('');
|
|
||||||
|
|
||||||
const reasoningBlock = reasoningText.length > 0 ? `:::thinking\n${reasoningText}\n:::\n` : '';
|
|
||||||
|
|
||||||
return `${reasoningBlock}${this.streamHandler.tokens.join('')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendCompletion(payload, { onProgress, abortController }) {
|
|
||||||
if (!abortController) {
|
|
||||||
abortController = new AbortController();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { signal } = abortController;
|
|
||||||
|
|
||||||
const modelOptions = { ...this.modelOptions };
|
|
||||||
if (typeof onProgress === 'function') {
|
|
||||||
modelOptions.stream = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('modelOptions', { modelOptions });
|
|
||||||
const metadata = {
|
|
||||||
user_id: this.user,
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
|
||||||
stream,
|
|
||||||
model,
|
|
||||||
temperature,
|
|
||||||
maxOutputTokens,
|
|
||||||
stop: stop_sequences,
|
|
||||||
topP: top_p,
|
|
||||||
topK: top_k,
|
|
||||||
} = this.modelOptions;
|
|
||||||
|
|
||||||
let requestOptions = {
|
|
||||||
model,
|
|
||||||
stream: stream || true,
|
|
||||||
stop_sequences,
|
|
||||||
temperature,
|
|
||||||
metadata,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.useMessages) {
|
|
||||||
requestOptions.messages = payload;
|
|
||||||
requestOptions.max_tokens =
|
|
||||||
maxOutputTokens || anthropicSettings.maxOutputTokens.reset(requestOptions.model);
|
|
||||||
} else {
|
|
||||||
requestOptions.prompt = payload;
|
|
||||||
requestOptions.max_tokens_to_sample = maxOutputTokens || legacy.maxOutputTokens.default;
|
|
||||||
}
|
|
||||||
|
|
||||||
requestOptions = configureReasoning(requestOptions, {
|
|
||||||
thinking: this.options.thinking,
|
|
||||||
thinkingBudget: this.options.thinkingBudget,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!/claude-3[-.]7/.test(model)) {
|
|
||||||
requestOptions.top_p = top_p;
|
|
||||||
requestOptions.top_k = top_k;
|
|
||||||
} else if (requestOptions.thinking == null) {
|
|
||||||
requestOptions.topP = top_p;
|
|
||||||
requestOptions.topK = top_k;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.systemMessage && this.supportsCacheControl === true) {
|
|
||||||
requestOptions.system = [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: this.systemMessage,
|
|
||||||
cache_control: { type: 'ephemeral' },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
} else if (this.systemMessage) {
|
|
||||||
requestOptions.system = this.systemMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.supportsCacheControl === true && this.useMessages) {
|
|
||||||
requestOptions.messages = addCacheControl(requestOptions.messages);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('[AnthropicClient]', { ...requestOptions });
|
|
||||||
const handlers = createStreamEventHandlers(this.options.res);
|
|
||||||
this.streamHandler = new SplitStreamHandler({
|
|
||||||
accumulate: true,
|
|
||||||
runId: this.responseMessageId,
|
|
||||||
handlers,
|
|
||||||
});
|
|
||||||
|
|
||||||
let intermediateReply = this.streamHandler.tokens;
|
|
||||||
|
|
||||||
const maxRetries = 3;
|
|
||||||
const streamRate = this.options.streamRate ?? Constants.DEFAULT_STREAM_RATE;
|
|
||||||
async function processResponse() {
|
|
||||||
let attempts = 0;
|
|
||||||
|
|
||||||
while (attempts < maxRetries) {
|
|
||||||
let response;
|
|
||||||
try {
|
|
||||||
const client = this.getClient(requestOptions);
|
|
||||||
response = await this.createResponse(client, requestOptions);
|
|
||||||
|
|
||||||
signal.addEventListener('abort', () => {
|
|
||||||
logger.debug('[AnthropicClient] message aborted!');
|
|
||||||
if (response.controller?.abort) {
|
|
||||||
response.controller.abort();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for await (const completion of response) {
|
|
||||||
const type = completion?.type ?? '';
|
|
||||||
if (tokenEventTypes.has(type)) {
|
|
||||||
logger.debug(`[AnthropicClient] ${type}`, completion);
|
|
||||||
this[type] = completion;
|
|
||||||
}
|
|
||||||
this.streamHandler.handle(completion);
|
|
||||||
await sleep(streamRate);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
} catch (error) {
|
|
||||||
attempts += 1;
|
|
||||||
logger.warn(
|
|
||||||
`User: ${this.user} | Anthropic Request ${attempts} failed: ${error.message}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (attempts < maxRetries) {
|
|
||||||
await delayBeforeRetry(attempts, 350);
|
|
||||||
} else if (this.streamHandler && this.streamHandler.reasoningTokens.length) {
|
|
||||||
return this.getStreamText();
|
|
||||||
} else if (intermediateReply.length > 0) {
|
|
||||||
return this.getStreamText(intermediateReply);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Operation failed after ${maxRetries} attempts: ${error.message}`);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
signal.removeEventListener('abort', () => {
|
|
||||||
logger.debug('[AnthropicClient] message aborted!');
|
|
||||||
if (response.controller?.abort) {
|
|
||||||
response.controller.abort();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await processResponse.bind(this)();
|
|
||||||
return this.getStreamText(intermediateReply);
|
|
||||||
}
|
|
||||||
|
|
||||||
getSaveOptions() {
|
|
||||||
return {
|
|
||||||
maxContextTokens: this.options.maxContextTokens,
|
|
||||||
artifacts: this.options.artifacts,
|
|
||||||
promptPrefix: this.options.promptPrefix,
|
|
||||||
modelLabel: this.options.modelLabel,
|
|
||||||
promptCache: this.options.promptCache,
|
|
||||||
thinking: this.options.thinking,
|
|
||||||
thinkingBudget: this.options.thinkingBudget,
|
|
||||||
resendFiles: this.options.resendFiles,
|
|
||||||
iconURL: this.options.iconURL,
|
|
||||||
greeting: this.options.greeting,
|
|
||||||
spec: this.options.spec,
|
|
||||||
...this.modelOptions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
getBuildMessagesOptions() {
|
|
||||||
logger.debug("AnthropicClient doesn't use getBuildMessagesOptions");
|
|
||||||
}
|
|
||||||
|
|
||||||
getEncoding() {
|
|
||||||
return 'cl100k_base';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the token count of a given text. It also checks and resets the tokenizers if necessary.
|
|
||||||
* @param {string} text - The text to get the token count for.
|
|
||||||
* @returns {number} The token count of the given text.
|
|
||||||
*/
|
|
||||||
getTokenCount(text) {
|
|
||||||
const encoding = this.getEncoding();
|
|
||||||
return Tokenizer.getTokenCount(text, encoding);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a concise title for a conversation based on the user's input text and response.
|
|
||||||
* Involves sending a chat completion request with specific instructions for title generation.
|
|
||||||
*
|
|
||||||
* This function capitlizes on [Anthropic's function calling training](https://docs.anthropic.com/claude/docs/functions-external-tools).
|
|
||||||
*
|
|
||||||
* @param {Object} params - The parameters for the conversation title generation.
|
|
||||||
* @param {string} params.text - The user's input.
|
|
||||||
* @param {string} [params.responseText=''] - The AI's immediate response to the user.
|
|
||||||
*
|
|
||||||
* @returns {Promise<string | 'New Chat'>} A promise that resolves to the generated conversation title.
|
|
||||||
* In case of failure, it will return the default title, "New Chat".
|
|
||||||
*/
|
|
||||||
async titleConvo({ text, responseText = '' }) {
|
|
||||||
let title = 'New Chat';
|
|
||||||
this.message_delta = undefined;
|
|
||||||
this.message_start = undefined;
|
|
||||||
const convo = `<initial_message>
|
|
||||||
${truncateText(text)}
|
|
||||||
</initial_message>
|
|
||||||
<response>
|
|
||||||
${JSON.stringify(truncateText(responseText))}
|
|
||||||
</response>`;
|
|
||||||
|
|
||||||
const { ANTHROPIC_TITLE_MODEL } = process.env ?? {};
|
|
||||||
const model = this.options.titleModel ?? ANTHROPIC_TITLE_MODEL ?? 'claude-3-haiku-20240307';
|
|
||||||
const system = titleFunctionPrompt;
|
|
||||||
|
|
||||||
const titleChatCompletion = async () => {
|
|
||||||
const content = `<conversation_context>
|
|
||||||
${convo}
|
|
||||||
</conversation_context>
|
|
||||||
|
|
||||||
Please generate a title for this conversation.`;
|
|
||||||
|
|
||||||
const titleMessage = { role: 'user', content };
|
|
||||||
const requestOptions = {
|
|
||||||
model,
|
|
||||||
temperature: 0.3,
|
|
||||||
max_tokens: 1024,
|
|
||||||
system,
|
|
||||||
stop_sequences: ['\n\nHuman:', '\n\nAssistant', '</function_calls>'],
|
|
||||||
messages: [titleMessage],
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.createResponse(
|
|
||||||
this.getClient(requestOptions),
|
|
||||||
requestOptions,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
let promptTokens = response?.usage?.input_tokens;
|
|
||||||
let completionTokens = response?.usage?.output_tokens;
|
|
||||||
if (!promptTokens) {
|
|
||||||
promptTokens = this.getTokenCountForMessage(titleMessage);
|
|
||||||
promptTokens += this.getTokenCountForMessage({ role: 'system', content: system });
|
|
||||||
}
|
|
||||||
if (!completionTokens) {
|
|
||||||
completionTokens = this.getTokenCountForMessage(response.content[0]);
|
|
||||||
}
|
|
||||||
await this.recordTokenUsage({
|
|
||||||
model,
|
|
||||||
promptTokens,
|
|
||||||
completionTokens,
|
|
||||||
context: 'title',
|
|
||||||
});
|
|
||||||
const text = response.content[0].text;
|
|
||||||
title = parseParamFromPrompt(text, 'title');
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('[AnthropicClient] There was an issue generating the title', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await titleChatCompletion();
|
|
||||||
logger.debug('[AnthropicClient] Convo Title: ' + title);
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = AnthropicClient;
|
|
||||||
|
|
@ -2,6 +2,7 @@ const crypto = require('crypto');
|
||||||
const fetch = require('node-fetch');
|
const fetch = require('node-fetch');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const {
|
const {
|
||||||
|
countTokens,
|
||||||
getBalanceConfig,
|
getBalanceConfig,
|
||||||
extractFileContext,
|
extractFileContext,
|
||||||
encodeAndFormatAudios,
|
encodeAndFormatAudios,
|
||||||
|
|
@ -17,14 +18,20 @@ const {
|
||||||
EModelEndpoint,
|
EModelEndpoint,
|
||||||
isParamEndpoint,
|
isParamEndpoint,
|
||||||
isAgentsEndpoint,
|
isAgentsEndpoint,
|
||||||
|
isEphemeralAgentId,
|
||||||
supportsBalanceCheck,
|
supportsBalanceCheck,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { getMessages, saveMessage, updateMessage, saveConvo, getConvo } = require('~/models');
|
const {
|
||||||
|
updateMessage,
|
||||||
|
getMessages,
|
||||||
|
saveMessage,
|
||||||
|
saveConvo,
|
||||||
|
getConvo,
|
||||||
|
getFiles,
|
||||||
|
} = require('~/models');
|
||||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||||
const { checkBalance } = require('~/models/balanceMethods');
|
const { checkBalance } = require('~/models/balanceMethods');
|
||||||
const { truncateToolCallOutputs } = require('./prompts');
|
const { truncateToolCallOutputs } = require('./prompts');
|
||||||
const countTokens = require('~/server/utils/countTokens');
|
|
||||||
const { getFiles } = require('~/models/File');
|
|
||||||
const TextStream = require('./TextStream');
|
const TextStream = require('./TextStream');
|
||||||
|
|
||||||
class BaseClient {
|
class BaseClient {
|
||||||
|
|
@ -708,7 +715,7 @@ class BaseClient {
|
||||||
iconURL: this.options.iconURL,
|
iconURL: this.options.iconURL,
|
||||||
endpoint: this.options.endpoint,
|
endpoint: this.options.endpoint,
|
||||||
...(this.metadata ?? {}),
|
...(this.metadata ?? {}),
|
||||||
metadata,
|
metadata: Object.keys(metadata ?? {}).length > 0 ? metadata : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof completion === 'string') {
|
if (typeof completion === 'string') {
|
||||||
|
|
@ -931,6 +938,7 @@ class BaseClient {
|
||||||
throw new Error('User mismatch.');
|
throw new Error('User mismatch.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasAddedConvo = this.options?.req?.body?.addedConvo != null;
|
||||||
const savedMessage = await saveMessage(
|
const savedMessage = await saveMessage(
|
||||||
this.options?.req,
|
this.options?.req,
|
||||||
{
|
{
|
||||||
|
|
@ -938,6 +946,7 @@ class BaseClient {
|
||||||
endpoint: this.options.endpoint,
|
endpoint: this.options.endpoint,
|
||||||
unfinished: false,
|
unfinished: false,
|
||||||
user,
|
user,
|
||||||
|
...(hasAddedConvo && { addedConvo: true }),
|
||||||
},
|
},
|
||||||
{ context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveMessage' },
|
{ context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveMessage' },
|
||||||
);
|
);
|
||||||
|
|
@ -960,6 +969,13 @@ class BaseClient {
|
||||||
|
|
||||||
const unsetFields = {};
|
const unsetFields = {};
|
||||||
const exceptions = new Set(['spec', 'iconURL']);
|
const exceptions = new Set(['spec', 'iconURL']);
|
||||||
|
const hasNonEphemeralAgent =
|
||||||
|
isAgentsEndpoint(this.options.endpoint) &&
|
||||||
|
endpointOptions?.agent_id &&
|
||||||
|
!isEphemeralAgentId(endpointOptions.agent_id);
|
||||||
|
if (hasNonEphemeralAgent) {
|
||||||
|
exceptions.add('model');
|
||||||
|
}
|
||||||
if (existingConvo != null) {
|
if (existingConvo != null) {
|
||||||
this.fetchedConvo = true;
|
this.fetchedConvo = true;
|
||||||
for (const key in existingConvo) {
|
for (const key in existingConvo) {
|
||||||
|
|
@ -1011,7 +1027,8 @@ class BaseClient {
|
||||||
* @param {Object} options - The options for the function.
|
* @param {Object} options - The options for the function.
|
||||||
* @param {TMessage[]} options.messages - An array of message objects. Each object should have either an 'id' or 'messageId' property, and may have a 'parentMessageId' property.
|
* @param {TMessage[]} options.messages - An array of message objects. Each object should have either an 'id' or 'messageId' property, and may have a 'parentMessageId' property.
|
||||||
* @param {string} options.parentMessageId - The ID of the parent message to start the traversal from.
|
* @param {string} options.parentMessageId - The ID of the parent message to start the traversal from.
|
||||||
* @param {Function} [options.mapMethod] - An optional function to map over the ordered messages. If provided, it will be applied to each message in the resulting array.
|
* @param {Function} [options.mapMethod] - An optional function to map over the ordered messages. Applied conditionally based on mapCondition.
|
||||||
|
* @param {(message: TMessage) => boolean} [options.mapCondition] - An optional function to determine whether mapMethod should be applied to a given message. If not provided and mapMethod is set, mapMethod applies to all messages.
|
||||||
* @param {boolean} [options.summary=false] - If set to true, the traversal modifies messages with 'summary' and 'summaryTokenCount' properties and stops at the message with a 'summary' property.
|
* @param {boolean} [options.summary=false] - If set to true, the traversal modifies messages with 'summary' and 'summaryTokenCount' properties and stops at the message with a 'summary' property.
|
||||||
* @returns {TMessage[]} An array containing the messages in the order they should be displayed, starting with the most recent message with a 'summary' property if the 'summary' option is true, and ending with the message identified by 'parentMessageId'.
|
* @returns {TMessage[]} An array containing the messages in the order they should be displayed, starting with the most recent message with a 'summary' property if the 'summary' option is true, and ending with the message identified by 'parentMessageId'.
|
||||||
*/
|
*/
|
||||||
|
|
@ -1019,6 +1036,7 @@ class BaseClient {
|
||||||
messages,
|
messages,
|
||||||
parentMessageId,
|
parentMessageId,
|
||||||
mapMethod = null,
|
mapMethod = null,
|
||||||
|
mapCondition = null,
|
||||||
summary = false,
|
summary = false,
|
||||||
}) {
|
}) {
|
||||||
if (!messages || messages.length === 0) {
|
if (!messages || messages.length === 0) {
|
||||||
|
|
@ -1053,7 +1071,9 @@ class BaseClient {
|
||||||
message.tokenCount = message.summaryTokenCount;
|
message.tokenCount = message.summaryTokenCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
orderedMessages.push(message);
|
const shouldMap = mapMethod != null && (mapCondition != null ? mapCondition(message) : true);
|
||||||
|
const processedMessage = shouldMap ? mapMethod(message) : message;
|
||||||
|
orderedMessages.push(processedMessage);
|
||||||
|
|
||||||
if (summary && message.summary) {
|
if (summary && message.summary) {
|
||||||
break;
|
break;
|
||||||
|
|
@ -1064,11 +1084,6 @@ class BaseClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
orderedMessages.reverse();
|
orderedMessages.reverse();
|
||||||
|
|
||||||
if (mapMethod) {
|
|
||||||
return orderedMessages.map(mapMethod);
|
|
||||||
}
|
|
||||||
|
|
||||||
return orderedMessages;
|
return orderedMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,994 +0,0 @@
|
||||||
const { google } = require('googleapis');
|
|
||||||
const { sleep } = require('@librechat/agents');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
|
||||||
const { getModelMaxTokens } = require('@librechat/api');
|
|
||||||
const { concat } = require('@langchain/core/utils/stream');
|
|
||||||
const { ChatVertexAI } = require('@langchain/google-vertexai');
|
|
||||||
const { Tokenizer, getSafetySettings } = require('@librechat/api');
|
|
||||||
const { ChatGoogleGenerativeAI } = require('@langchain/google-genai');
|
|
||||||
const { GoogleGenerativeAI: GenAI } = require('@google/generative-ai');
|
|
||||||
const { HumanMessage, SystemMessage } = require('@langchain/core/messages');
|
|
||||||
const {
|
|
||||||
googleGenConfigSchema,
|
|
||||||
validateVisionModel,
|
|
||||||
getResponseSender,
|
|
||||||
endpointSettings,
|
|
||||||
parseTextParts,
|
|
||||||
EModelEndpoint,
|
|
||||||
googleSettings,
|
|
||||||
ContentTypes,
|
|
||||||
VisionModes,
|
|
||||||
ErrorTypes,
|
|
||||||
Constants,
|
|
||||||
AuthKeys,
|
|
||||||
} = require('librechat-data-provider');
|
|
||||||
const { encodeAndFormat } = require('~/server/services/Files/images');
|
|
||||||
const { spendTokens } = require('~/models/spendTokens');
|
|
||||||
const {
|
|
||||||
formatMessage,
|
|
||||||
createContextHandlers,
|
|
||||||
titleInstruction,
|
|
||||||
truncateText,
|
|
||||||
} = require('./prompts');
|
|
||||||
const BaseClient = require('./BaseClient');
|
|
||||||
|
|
||||||
const loc = process.env.GOOGLE_LOC || 'us-central1';
|
|
||||||
const publisher = 'google';
|
|
||||||
const endpointPrefix =
|
|
||||||
loc === 'global' ? 'aiplatform.googleapis.com' : `${loc}-aiplatform.googleapis.com`;
|
|
||||||
|
|
||||||
const settings = endpointSettings[EModelEndpoint.google];
|
|
||||||
const EXCLUDED_GENAI_MODELS = /gemini-(?:1\.0|1-0|pro)/;
|
|
||||||
|
|
||||||
class GoogleClient extends BaseClient {
|
|
||||||
constructor(credentials, options = {}) {
|
|
||||||
super('apiKey', options);
|
|
||||||
let creds = {};
|
|
||||||
|
|
||||||
if (typeof credentials === 'string') {
|
|
||||||
creds = JSON.parse(credentials);
|
|
||||||
} else if (credentials) {
|
|
||||||
creds = credentials;
|
|
||||||
}
|
|
||||||
|
|
||||||
const serviceKey = creds[AuthKeys.GOOGLE_SERVICE_KEY] ?? {};
|
|
||||||
this.serviceKey =
|
|
||||||
serviceKey && typeof serviceKey === 'string' ? JSON.parse(serviceKey) : (serviceKey ?? {});
|
|
||||||
/** @type {string | null | undefined} */
|
|
||||||
this.project_id = this.serviceKey.project_id;
|
|
||||||
this.client_email = this.serviceKey.client_email;
|
|
||||||
this.private_key = this.serviceKey.private_key;
|
|
||||||
this.access_token = null;
|
|
||||||
|
|
||||||
this.apiKey = creds[AuthKeys.GOOGLE_API_KEY];
|
|
||||||
|
|
||||||
this.reverseProxyUrl = options.reverseProxyUrl;
|
|
||||||
|
|
||||||
this.authHeader = options.authHeader;
|
|
||||||
|
|
||||||
/** @type {UsageMetadata | undefined} */
|
|
||||||
this.usage;
|
|
||||||
/** The key for the usage object's input tokens
|
|
||||||
* @type {string} */
|
|
||||||
this.inputTokensKey = 'input_tokens';
|
|
||||||
/** The key for the usage object's output tokens
|
|
||||||
* @type {string} */
|
|
||||||
this.outputTokensKey = 'output_tokens';
|
|
||||||
this.visionMode = VisionModes.generative;
|
|
||||||
/** @type {string} */
|
|
||||||
this.systemMessage;
|
|
||||||
if (options.skipSetOptions) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.setOptions(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Google specific methods */
|
|
||||||
constructUrl() {
|
|
||||||
return `https://${endpointPrefix}/v1/projects/${this.project_id}/locations/${loc}/publishers/${publisher}/models/${this.modelOptions.model}:serverStreamingPredict`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getClient() {
|
|
||||||
const scopes = ['https://www.googleapis.com/auth/cloud-platform'];
|
|
||||||
const jwtClient = new google.auth.JWT(this.client_email, null, this.private_key, scopes);
|
|
||||||
|
|
||||||
jwtClient.authorize((err) => {
|
|
||||||
if (err) {
|
|
||||||
logger.error('jwtClient failed to authorize', err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return jwtClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAccessToken() {
|
|
||||||
const scopes = ['https://www.googleapis.com/auth/cloud-platform'];
|
|
||||||
const jwtClient = new google.auth.JWT(this.client_email, null, this.private_key, scopes);
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
jwtClient.authorize((err, tokens) => {
|
|
||||||
if (err) {
|
|
||||||
logger.error('jwtClient failed to authorize', err);
|
|
||||||
reject(err);
|
|
||||||
} else {
|
|
||||||
resolve(tokens.access_token);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Required Client methods */
|
|
||||||
setOptions(options) {
|
|
||||||
if (this.options && !this.options.replaceOptions) {
|
|
||||||
// nested options aren't spread properly, so we need to do this manually
|
|
||||||
this.options.modelOptions = {
|
|
||||||
...this.options.modelOptions,
|
|
||||||
...options.modelOptions,
|
|
||||||
};
|
|
||||||
delete options.modelOptions;
|
|
||||||
// now we can merge options
|
|
||||||
this.options = {
|
|
||||||
...this.options,
|
|
||||||
...options,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
this.options = options;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.modelOptions = this.options.modelOptions || {};
|
|
||||||
|
|
||||||
this.options.attachments?.then((attachments) => this.checkVisionRequest(attachments));
|
|
||||||
|
|
||||||
/** @type {boolean} Whether using a "GenerativeAI" Model */
|
|
||||||
this.isGenerativeModel = /gemini|learnlm|gemma/.test(this.modelOptions.model);
|
|
||||||
|
|
||||||
this.maxContextTokens =
|
|
||||||
this.options.maxContextTokens ??
|
|
||||||
getModelMaxTokens(this.modelOptions.model, EModelEndpoint.google);
|
|
||||||
|
|
||||||
// The max prompt tokens is determined by the max context tokens minus the max response tokens.
|
|
||||||
// Earlier messages will be dropped until the prompt is within the limit.
|
|
||||||
this.maxResponseTokens = this.modelOptions.maxOutputTokens || settings.maxOutputTokens.default;
|
|
||||||
|
|
||||||
if (this.maxContextTokens > 32000) {
|
|
||||||
this.maxContextTokens = this.maxContextTokens - this.maxResponseTokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.maxPromptTokens =
|
|
||||||
this.options.maxPromptTokens || this.maxContextTokens - this.maxResponseTokens;
|
|
||||||
|
|
||||||
if (this.maxPromptTokens + this.maxResponseTokens > this.maxContextTokens) {
|
|
||||||
throw new Error(
|
|
||||||
`maxPromptTokens + maxOutputTokens (${this.maxPromptTokens} + ${this.maxResponseTokens} = ${
|
|
||||||
this.maxPromptTokens + this.maxResponseTokens
|
|
||||||
}) must be less than or equal to maxContextTokens (${this.maxContextTokens})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add thinking configuration
|
|
||||||
this.modelOptions.thinkingConfig = {
|
|
||||||
thinkingBudget:
|
|
||||||
(this.modelOptions.thinking ?? googleSettings.thinking.default)
|
|
||||||
? this.modelOptions.thinkingBudget
|
|
||||||
: 0,
|
|
||||||
};
|
|
||||||
delete this.modelOptions.thinking;
|
|
||||||
delete this.modelOptions.thinkingBudget;
|
|
||||||
|
|
||||||
this.sender =
|
|
||||||
this.options.sender ??
|
|
||||||
getResponseSender({
|
|
||||||
model: this.modelOptions.model,
|
|
||||||
endpoint: EModelEndpoint.google,
|
|
||||||
modelLabel: this.options.modelLabel,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.userLabel = this.options.userLabel || 'User';
|
|
||||||
this.modelLabel = this.options.modelLabel || 'Assistant';
|
|
||||||
|
|
||||||
if (this.options.reverseProxyUrl) {
|
|
||||||
this.completionsUrl = this.options.reverseProxyUrl;
|
|
||||||
} else {
|
|
||||||
this.completionsUrl = this.constructUrl();
|
|
||||||
}
|
|
||||||
|
|
||||||
let promptPrefix = (this.options.promptPrefix ?? '').trim();
|
|
||||||
if (typeof this.options.artifactsPrompt === 'string' && this.options.artifactsPrompt) {
|
|
||||||
promptPrefix = `${promptPrefix ?? ''}\n${this.options.artifactsPrompt}`.trim();
|
|
||||||
}
|
|
||||||
this.systemMessage = promptPrefix;
|
|
||||||
this.initializeClient();
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* Checks if the model is a vision model based on request attachments and sets the appropriate options:
|
|
||||||
* @param {MongoFile[]} attachments
|
|
||||||
*/
|
|
||||||
checkVisionRequest(attachments) {
|
|
||||||
/* Validation vision request */
|
|
||||||
this.defaultVisionModel =
|
|
||||||
this.options.visionModel ??
|
|
||||||
(!EXCLUDED_GENAI_MODELS.test(this.modelOptions.model)
|
|
||||||
? this.modelOptions.model
|
|
||||||
: 'gemini-pro-vision');
|
|
||||||
const availableModels = this.options.modelsConfig?.[EModelEndpoint.google];
|
|
||||||
this.isVisionModel = validateVisionModel({ model: this.modelOptions.model, availableModels });
|
|
||||||
|
|
||||||
if (
|
|
||||||
attachments &&
|
|
||||||
attachments.some((file) => file?.type && file?.type?.includes('image')) &&
|
|
||||||
availableModels?.includes(this.defaultVisionModel) &&
|
|
||||||
!this.isVisionModel
|
|
||||||
) {
|
|
||||||
this.modelOptions.model = this.defaultVisionModel;
|
|
||||||
this.isVisionModel = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isVisionModel && !attachments && this.modelOptions.model.includes('gemini-pro')) {
|
|
||||||
this.modelOptions.model = 'gemini-pro';
|
|
||||||
this.isVisionModel = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formatMessages() {
|
|
||||||
return ((message) => {
|
|
||||||
const msg = {
|
|
||||||
author: message?.author ?? (message.isCreatedByUser ? this.userLabel : this.modelLabel),
|
|
||||||
content: message?.content ?? message.text,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!message.image_urls?.length) {
|
|
||||||
return msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
msg.content = (
|
|
||||||
!Array.isArray(msg.content)
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
type: ContentTypes.TEXT,
|
|
||||||
[ContentTypes.TEXT]: msg.content,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: msg.content
|
|
||||||
).concat(message.image_urls);
|
|
||||||
|
|
||||||
return msg;
|
|
||||||
}).bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats messages for generative AI
|
|
||||||
* @param {TMessage[]} messages
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
async formatGenerativeMessages(messages) {
|
|
||||||
const formattedMessages = [];
|
|
||||||
const attachments = await this.options.attachments;
|
|
||||||
const latestMessage = { ...messages[messages.length - 1] };
|
|
||||||
const files = await this.addImageURLs(latestMessage, attachments, VisionModes.generative);
|
|
||||||
this.options.attachments = files;
|
|
||||||
messages[messages.length - 1] = latestMessage;
|
|
||||||
|
|
||||||
for (const _message of messages) {
|
|
||||||
const role = _message.isCreatedByUser ? this.userLabel : this.modelLabel;
|
|
||||||
const parts = [];
|
|
||||||
parts.push({ text: _message.text });
|
|
||||||
if (!_message.image_urls?.length) {
|
|
||||||
formattedMessages.push({ role, parts });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const images of _message.image_urls) {
|
|
||||||
if (images.inlineData) {
|
|
||||||
parts.push({ inlineData: images.inlineData });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formattedMessages.push({ role, parts });
|
|
||||||
}
|
|
||||||
|
|
||||||
return formattedMessages;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* Adds image URLs to the message object and returns the files
|
|
||||||
*
|
|
||||||
* @param {TMessage[]} messages
|
|
||||||
* @param {MongoFile[]} files
|
|
||||||
* @returns {Promise<MongoFile[]>}
|
|
||||||
*/
|
|
||||||
async addImageURLs(message, attachments, mode = '') {
|
|
||||||
const { files, image_urls } = await encodeAndFormat(
|
|
||||||
this.options.req,
|
|
||||||
attachments,
|
|
||||||
{
|
|
||||||
endpoint: EModelEndpoint.google,
|
|
||||||
},
|
|
||||||
mode,
|
|
||||||
);
|
|
||||||
message.image_urls = image_urls.length ? image_urls : undefined;
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the augmented prompt for attachments
|
|
||||||
* TODO: Add File API Support
|
|
||||||
* @param {TMessage[]} messages
|
|
||||||
*/
|
|
||||||
async buildAugmentedPrompt(messages = []) {
|
|
||||||
const attachments = await this.options.attachments;
|
|
||||||
const latestMessage = { ...messages[messages.length - 1] };
|
|
||||||
this.contextHandlers = createContextHandlers(this.options.req, latestMessage.text);
|
|
||||||
|
|
||||||
if (this.contextHandlers) {
|
|
||||||
for (const file of attachments) {
|
|
||||||
if (file.embedded) {
|
|
||||||
this.contextHandlers?.processFile(file);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (file.metadata?.fileIdentifier) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.augmentedPrompt = await this.contextHandlers.createContext();
|
|
||||||
this.systemMessage = this.augmentedPrompt + this.systemMessage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async buildVisionMessages(messages = [], parentMessageId) {
|
|
||||||
const attachments = await this.options.attachments;
|
|
||||||
const latestMessage = { ...messages[messages.length - 1] };
|
|
||||||
await this.buildAugmentedPrompt(messages);
|
|
||||||
|
|
||||||
const { prompt } = await this.buildMessagesPrompt(messages, parentMessageId);
|
|
||||||
|
|
||||||
const files = await this.addImageURLs(latestMessage, attachments);
|
|
||||||
|
|
||||||
this.options.attachments = files;
|
|
||||||
|
|
||||||
latestMessage.text = prompt;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
instances: [
|
|
||||||
{
|
|
||||||
messages: [new HumanMessage(formatMessage({ message: latestMessage }))],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
return { prompt: payload };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {TMessage[]} [messages=[]] */
|
|
||||||
async buildGenerativeMessages(messages = []) {
|
|
||||||
this.userLabel = 'user';
|
|
||||||
this.modelLabel = 'model';
|
|
||||||
const promises = [];
|
|
||||||
promises.push(await this.formatGenerativeMessages(messages));
|
|
||||||
promises.push(this.buildAugmentedPrompt(messages));
|
|
||||||
const [formattedMessages] = await Promise.all(promises);
|
|
||||||
return { prompt: formattedMessages };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {TMessage[]} [messages=[]]
|
|
||||||
* @param {string} [parentMessageId]
|
|
||||||
*/
|
|
||||||
async buildMessages(_messages = [], parentMessageId) {
|
|
||||||
if (!this.isGenerativeModel && !this.project_id) {
|
|
||||||
throw new Error('[GoogleClient] PaLM 2 and Codey models are no longer supported.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.systemMessage) {
|
|
||||||
const instructionsTokenCount = this.getTokenCount(this.systemMessage);
|
|
||||||
|
|
||||||
this.maxContextTokens = this.maxContextTokens - instructionsTokenCount;
|
|
||||||
if (this.maxContextTokens < 0) {
|
|
||||||
const info = `${instructionsTokenCount} / ${this.maxContextTokens}`;
|
|
||||||
const errorMessage = `{ "type": "${ErrorTypes.INPUT_LENGTH}", "info": "${info}" }`;
|
|
||||||
logger.warn(`Instructions token count exceeds max context (${info}).`);
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < _messages.length; i++) {
|
|
||||||
const message = _messages[i];
|
|
||||||
if (!message.tokenCount) {
|
|
||||||
_messages[i].tokenCount = this.getTokenCountForMessage({
|
|
||||||
role: message.isCreatedByUser ? 'user' : 'assistant',
|
|
||||||
content: message.content ?? message.text,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
payload: messages,
|
|
||||||
tokenCountMap,
|
|
||||||
promptTokens,
|
|
||||||
} = await this.handleContextStrategy({
|
|
||||||
orderedMessages: _messages,
|
|
||||||
formattedMessages: _messages,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!this.project_id && !EXCLUDED_GENAI_MODELS.test(this.modelOptions.model)) {
|
|
||||||
const result = await this.buildGenerativeMessages(messages);
|
|
||||||
result.tokenCountMap = tokenCountMap;
|
|
||||||
result.promptTokens = promptTokens;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.options.attachments && this.isGenerativeModel) {
|
|
||||||
const result = this.buildVisionMessages(messages, parentMessageId);
|
|
||||||
result.tokenCountMap = tokenCountMap;
|
|
||||||
result.promptTokens = promptTokens;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload = {
|
|
||||||
instances: [
|
|
||||||
{
|
|
||||||
messages: messages
|
|
||||||
.map(this.formatMessages())
|
|
||||||
.map((msg) => ({ ...msg, role: msg.author === 'User' ? 'user' : 'assistant' }))
|
|
||||||
.map((message) => formatMessage({ message, langChain: true })),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.systemMessage) {
|
|
||||||
payload.instances[0].context = this.systemMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('[GoogleClient] buildMessages', payload);
|
|
||||||
return { prompt: payload, tokenCountMap, promptTokens };
|
|
||||||
}
|
|
||||||
|
|
||||||
async buildMessagesPrompt(messages, parentMessageId) {
|
|
||||||
const orderedMessages = this.constructor.getMessagesForConversation({
|
|
||||||
messages,
|
|
||||||
parentMessageId,
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('[GoogleClient]', {
|
|
||||||
orderedMessages,
|
|
||||||
parentMessageId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const formattedMessages = orderedMessages.map(this.formatMessages());
|
|
||||||
|
|
||||||
let lastAuthor = '';
|
|
||||||
let groupedMessages = [];
|
|
||||||
|
|
||||||
for (let message of formattedMessages) {
|
|
||||||
// If last author is not same as current author, add to new group
|
|
||||||
if (lastAuthor !== message.author) {
|
|
||||||
groupedMessages.push({
|
|
||||||
author: message.author,
|
|
||||||
content: [message.content],
|
|
||||||
});
|
|
||||||
lastAuthor = message.author;
|
|
||||||
// If same author, append content to the last group
|
|
||||||
} else {
|
|
||||||
groupedMessages[groupedMessages.length - 1].content.push(message.content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let identityPrefix = '';
|
|
||||||
if (this.options.userLabel) {
|
|
||||||
identityPrefix = `\nHuman's name: ${this.options.userLabel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.options.modelLabel) {
|
|
||||||
identityPrefix = `${identityPrefix}\nYou are ${this.options.modelLabel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let promptPrefix = (this.systemMessage ?? '').trim();
|
|
||||||
|
|
||||||
if (identityPrefix) {
|
|
||||||
promptPrefix = `${identityPrefix}${promptPrefix}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prompt AI to respond, empty if last message was from AI
|
|
||||||
let isEdited = lastAuthor === this.modelLabel;
|
|
||||||
const promptSuffix = isEdited ? '' : `${promptPrefix}\n\n${this.modelLabel}:\n`;
|
|
||||||
let currentTokenCount = isEdited
|
|
||||||
? this.getTokenCount(promptPrefix)
|
|
||||||
: this.getTokenCount(promptSuffix);
|
|
||||||
|
|
||||||
let promptBody = '';
|
|
||||||
const maxTokenCount = this.maxPromptTokens;
|
|
||||||
|
|
||||||
const context = [];
|
|
||||||
|
|
||||||
// Iterate backwards through the messages, adding them to the prompt until we reach the max token count.
|
|
||||||
// Do this within a recursive async function so that it doesn't block the event loop for too long.
|
|
||||||
// Also, remove the next message when the message that puts us over the token limit is created by the user.
|
|
||||||
// Otherwise, remove only the exceeding message. This is due to Anthropic's strict payload rule to start with "Human:".
|
|
||||||
const nextMessage = {
|
|
||||||
remove: false,
|
|
||||||
tokenCount: 0,
|
|
||||||
messageString: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildPromptBody = async () => {
|
|
||||||
if (currentTokenCount < maxTokenCount && groupedMessages.length > 0) {
|
|
||||||
const message = groupedMessages.pop();
|
|
||||||
const isCreatedByUser = message.author === this.userLabel;
|
|
||||||
// Use promptPrefix if message is edited assistant'
|
|
||||||
const messagePrefix =
|
|
||||||
isCreatedByUser || !isEdited
|
|
||||||
? `\n\n${message.author}:`
|
|
||||||
: `${promptPrefix}\n\n${message.author}:`;
|
|
||||||
const messageString = `${messagePrefix}\n${message.content}\n`;
|
|
||||||
let newPromptBody = `${messageString}${promptBody}`;
|
|
||||||
|
|
||||||
context.unshift(message);
|
|
||||||
|
|
||||||
const tokenCountForMessage = this.getTokenCount(messageString);
|
|
||||||
const newTokenCount = currentTokenCount + tokenCountForMessage;
|
|
||||||
|
|
||||||
if (!isCreatedByUser) {
|
|
||||||
nextMessage.messageString = messageString;
|
|
||||||
nextMessage.tokenCount = tokenCountForMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newTokenCount > maxTokenCount) {
|
|
||||||
if (!promptBody) {
|
|
||||||
// This is the first message, so we can't add it. Just throw an error.
|
|
||||||
throw new Error(
|
|
||||||
`Prompt is too long. Max token count is ${maxTokenCount}, but prompt is ${newTokenCount} tokens long.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, ths message would put us over the token limit, so don't add it.
|
|
||||||
// if created by user, remove next message, otherwise remove only this message
|
|
||||||
if (isCreatedByUser) {
|
|
||||||
nextMessage.remove = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
promptBody = newPromptBody;
|
|
||||||
currentTokenCount = newTokenCount;
|
|
||||||
|
|
||||||
// Switch off isEdited after using it for the first time
|
|
||||||
if (isEdited) {
|
|
||||||
isEdited = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// wait for next tick to avoid blocking the event loop
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
return buildPromptBody();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
await buildPromptBody();
|
|
||||||
|
|
||||||
if (nextMessage.remove) {
|
|
||||||
promptBody = promptBody.replace(nextMessage.messageString, '');
|
|
||||||
currentTokenCount -= nextMessage.tokenCount;
|
|
||||||
context.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
let prompt = `${promptBody}${promptSuffix}`.trim();
|
|
||||||
|
|
||||||
// Add 2 tokens for metadata after all messages have been counted.
|
|
||||||
currentTokenCount += 2;
|
|
||||||
|
|
||||||
// Use up to `this.maxContextTokens` tokens (prompt + response), but try to leave `this.maxTokens` tokens for the response.
|
|
||||||
this.modelOptions.maxOutputTokens = Math.min(
|
|
||||||
this.maxContextTokens - currentTokenCount,
|
|
||||||
this.maxResponseTokens,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { prompt, context };
|
|
||||||
}
|
|
||||||
|
|
||||||
createLLM(clientOptions) {
|
|
||||||
const model = clientOptions.modelName ?? clientOptions.model;
|
|
||||||
clientOptions.location = loc;
|
|
||||||
clientOptions.endpoint = endpointPrefix;
|
|
||||||
|
|
||||||
let requestOptions = null;
|
|
||||||
if (this.reverseProxyUrl) {
|
|
||||||
requestOptions = {
|
|
||||||
baseUrl: this.reverseProxyUrl,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.authHeader) {
|
|
||||||
requestOptions.customHeaders = {
|
|
||||||
Authorization: `Bearer ${this.apiKey}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.project_id != null) {
|
|
||||||
logger.debug('Creating VertexAI client');
|
|
||||||
this.visionMode = undefined;
|
|
||||||
clientOptions.streaming = true;
|
|
||||||
const client = new ChatVertexAI(clientOptions);
|
|
||||||
client.temperature = clientOptions.temperature;
|
|
||||||
client.topP = clientOptions.topP;
|
|
||||||
client.topK = clientOptions.topK;
|
|
||||||
client.topLogprobs = clientOptions.topLogprobs;
|
|
||||||
client.frequencyPenalty = clientOptions.frequencyPenalty;
|
|
||||||
client.presencePenalty = clientOptions.presencePenalty;
|
|
||||||
client.maxOutputTokens = clientOptions.maxOutputTokens;
|
|
||||||
return client;
|
|
||||||
} else if (!EXCLUDED_GENAI_MODELS.test(model)) {
|
|
||||||
logger.debug('Creating GenAI client');
|
|
||||||
return new GenAI(this.apiKey).getGenerativeModel({ model }, requestOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('Creating Chat Google Generative AI client');
|
|
||||||
return new ChatGoogleGenerativeAI({ ...clientOptions, apiKey: this.apiKey });
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeClient() {
|
|
||||||
let clientOptions = { ...this.modelOptions };
|
|
||||||
|
|
||||||
if (this.project_id) {
|
|
||||||
clientOptions['authOptions'] = {
|
|
||||||
credentials: {
|
|
||||||
...this.serviceKey,
|
|
||||||
},
|
|
||||||
projectId: this.project_id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isGenerativeModel && !this.project_id) {
|
|
||||||
clientOptions.modelName = clientOptions.model;
|
|
||||||
delete clientOptions.model;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.client = this.createLLM(clientOptions);
|
|
||||||
return this.client;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCompletion(_payload, options = {}) {
|
|
||||||
const { onProgress, abortController } = options;
|
|
||||||
const safetySettings = getSafetySettings(this.modelOptions.model);
|
|
||||||
const streamRate = this.options.streamRate ?? Constants.DEFAULT_STREAM_RATE;
|
|
||||||
const modelName = this.modelOptions.modelName ?? this.modelOptions.model ?? '';
|
|
||||||
|
|
||||||
let reply = '';
|
|
||||||
/** @type {Error} */
|
|
||||||
let error;
|
|
||||||
try {
|
|
||||||
if (!EXCLUDED_GENAI_MODELS.test(modelName) && !this.project_id) {
|
|
||||||
/** @type {GenerativeModel} */
|
|
||||||
const client = this.client;
|
|
||||||
/** @type {GenerateContentRequest} */
|
|
||||||
const requestOptions = {
|
|
||||||
safetySettings,
|
|
||||||
contents: _payload,
|
|
||||||
generationConfig: googleGenConfigSchema.parse(this.modelOptions),
|
|
||||||
};
|
|
||||||
|
|
||||||
const promptPrefix = (this.systemMessage ?? '').trim();
|
|
||||||
if (promptPrefix.length) {
|
|
||||||
requestOptions.systemInstruction = {
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
text: promptPrefix,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const delay = modelName.includes('flash') ? 8 : 15;
|
|
||||||
/** @type {GenAIUsageMetadata} */
|
|
||||||
let usageMetadata;
|
|
||||||
|
|
||||||
abortController.signal.addEventListener(
|
|
||||||
'abort',
|
|
||||||
() => {
|
|
||||||
logger.warn('[GoogleClient] Request was aborted', abortController.signal.reason);
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await client.generateContentStream(requestOptions, {
|
|
||||||
signal: abortController.signal,
|
|
||||||
});
|
|
||||||
for await (const chunk of result.stream) {
|
|
||||||
usageMetadata = !usageMetadata
|
|
||||||
? chunk?.usageMetadata
|
|
||||||
: Object.assign(usageMetadata, chunk?.usageMetadata);
|
|
||||||
const chunkText = chunk.text();
|
|
||||||
await this.generateTextStream(chunkText, onProgress, {
|
|
||||||
delay,
|
|
||||||
});
|
|
||||||
reply += chunkText;
|
|
||||||
await sleep(streamRate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (usageMetadata) {
|
|
||||||
this.usage = {
|
|
||||||
input_tokens: usageMetadata.promptTokenCount,
|
|
||||||
output_tokens: usageMetadata.candidatesTokenCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { instances } = _payload;
|
|
||||||
const { messages: messages, context } = instances?.[0] ?? {};
|
|
||||||
|
|
||||||
if (!this.isVisionModel && context && messages?.length > 0) {
|
|
||||||
messages.unshift(new SystemMessage(context));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {import('@langchain/core/messages').AIMessageChunk['usage_metadata']} */
|
|
||||||
let usageMetadata;
|
|
||||||
/** @type {ChatVertexAI} */
|
|
||||||
const client = this.client;
|
|
||||||
const stream = await client.stream(messages, {
|
|
||||||
signal: abortController.signal,
|
|
||||||
streamUsage: true,
|
|
||||||
safetySettings,
|
|
||||||
});
|
|
||||||
|
|
||||||
let delay = this.options.streamRate || 8;
|
|
||||||
|
|
||||||
if (!this.options.streamRate) {
|
|
||||||
if (this.isGenerativeModel) {
|
|
||||||
delay = 15;
|
|
||||||
}
|
|
||||||
if (modelName.includes('flash')) {
|
|
||||||
delay = 5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for await (const chunk of stream) {
|
|
||||||
if (chunk?.usage_metadata) {
|
|
||||||
const metadata = chunk.usage_metadata;
|
|
||||||
for (const key in metadata) {
|
|
||||||
if (Number.isNaN(metadata[key])) {
|
|
||||||
delete metadata[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
usageMetadata = !usageMetadata ? metadata : concat(usageMetadata, metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunkText = chunk?.content ?? '';
|
|
||||||
await this.generateTextStream(chunkText, onProgress, {
|
|
||||||
delay,
|
|
||||||
});
|
|
||||||
reply += chunkText;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (usageMetadata) {
|
|
||||||
this.usage = usageMetadata;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
error = e;
|
|
||||||
logger.error('[GoogleClient] There was an issue generating the completion', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error != null && reply === '') {
|
|
||||||
const errorMessage = `{ "type": "${ErrorTypes.GoogleError}", "info": "${
|
|
||||||
error.message ?? 'The Google provider failed to generate content, please contact the Admin.'
|
|
||||||
}" }`;
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
return reply;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get stream usage as returned by this client's API response.
|
|
||||||
* @returns {UsageMetadata} The stream usage object.
|
|
||||||
*/
|
|
||||||
getStreamUsage() {
|
|
||||||
return this.usage;
|
|
||||||
}
|
|
||||||
|
|
||||||
getMessageMapMethod() {
|
|
||||||
/**
|
|
||||||
* @param {TMessage} msg
|
|
||||||
*/
|
|
||||||
return (msg) => {
|
|
||||||
if (msg.text != null && msg.text && msg.text.startsWith(':::thinking')) {
|
|
||||||
msg.text = msg.text.replace(/:::thinking.*?:::/gs, '').trim();
|
|
||||||
} else if (msg.content != null) {
|
|
||||||
msg.text = parseTextParts(msg.content, true);
|
|
||||||
delete msg.content;
|
|
||||||
}
|
|
||||||
|
|
||||||
return msg;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates the correct token count for the current user message based on the token count map and API usage.
|
|
||||||
* Edge case: If the calculation results in a negative value, it returns the original estimate.
|
|
||||||
* If revisiting a conversation with a chat history entirely composed of token estimates,
|
|
||||||
* the cumulative token count going forward should become more accurate as the conversation progresses.
|
|
||||||
* @param {Object} params - The parameters for the calculation.
|
|
||||||
* @param {Record<string, number>} params.tokenCountMap - A map of message IDs to their token counts.
|
|
||||||
* @param {string} params.currentMessageId - The ID of the current message to calculate.
|
|
||||||
* @param {UsageMetadata} params.usage - The usage object returned by the API.
|
|
||||||
* @returns {number} The correct token count for the current user message.
|
|
||||||
*/
|
|
||||||
calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage }) {
|
|
||||||
const originalEstimate = tokenCountMap[currentMessageId] || 0;
|
|
||||||
|
|
||||||
if (!usage || typeof usage.input_tokens !== 'number') {
|
|
||||||
return originalEstimate;
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenCountMap[currentMessageId] = 0;
|
|
||||||
const totalTokensFromMap = Object.values(tokenCountMap).reduce((sum, count) => {
|
|
||||||
const numCount = Number(count);
|
|
||||||
return sum + (isNaN(numCount) ? 0 : numCount);
|
|
||||||
}, 0);
|
|
||||||
const totalInputTokens = usage.input_tokens ?? 0;
|
|
||||||
const currentMessageTokens = totalInputTokens - totalTokensFromMap;
|
|
||||||
return currentMessageTokens > 0 ? currentMessageTokens : originalEstimate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {object} params
|
|
||||||
* @param {number} params.promptTokens
|
|
||||||
* @param {number} params.completionTokens
|
|
||||||
* @param {UsageMetadata} [params.usage]
|
|
||||||
* @param {string} [params.model]
|
|
||||||
* @param {string} [params.context='message']
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async recordTokenUsage({ promptTokens, completionTokens, model, context = 'message' }) {
|
|
||||||
await spendTokens(
|
|
||||||
{
|
|
||||||
context,
|
|
||||||
user: this.user ?? this.options.req?.user?.id,
|
|
||||||
conversationId: this.conversationId,
|
|
||||||
model: model ?? this.modelOptions.model,
|
|
||||||
endpointTokenConfig: this.options.endpointTokenConfig,
|
|
||||||
},
|
|
||||||
{ promptTokens, completionTokens },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stripped-down logic for generating a title. This uses the non-streaming APIs, since the user does not see titles streaming
|
|
||||||
*/
|
|
||||||
async titleChatCompletion(_payload, options = {}) {
|
|
||||||
let reply = '';
|
|
||||||
const { abortController } = options;
|
|
||||||
|
|
||||||
const model =
|
|
||||||
this.options.titleModel ?? this.modelOptions.modelName ?? this.modelOptions.model ?? '';
|
|
||||||
const safetySettings = getSafetySettings(model);
|
|
||||||
if (!EXCLUDED_GENAI_MODELS.test(model) && !this.project_id) {
|
|
||||||
logger.debug('Identified titling model as GenAI version');
|
|
||||||
/** @type {GenerativeModel} */
|
|
||||||
const client = this.client;
|
|
||||||
const requestOptions = {
|
|
||||||
contents: _payload,
|
|
||||||
safetySettings,
|
|
||||||
generationConfig: {
|
|
||||||
temperature: 0.5,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await client.generateContent(requestOptions);
|
|
||||||
reply = result.response?.text();
|
|
||||||
return reply;
|
|
||||||
} else {
|
|
||||||
const { instances } = _payload;
|
|
||||||
const { messages } = instances?.[0] ?? {};
|
|
||||||
const titleResponse = await this.client.invoke(messages, {
|
|
||||||
signal: abortController.signal,
|
|
||||||
timeout: 7000,
|
|
||||||
safetySettings,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (titleResponse.usage_metadata) {
|
|
||||||
await this.recordTokenUsage({
|
|
||||||
model,
|
|
||||||
promptTokens: titleResponse.usage_metadata.input_tokens,
|
|
||||||
completionTokens: titleResponse.usage_metadata.output_tokens,
|
|
||||||
context: 'title',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
reply = titleResponse.content;
|
|
||||||
return reply;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async titleConvo({ text, responseText = '' }) {
|
|
||||||
let title = 'New Chat';
|
|
||||||
const convo = `||>User:
|
|
||||||
"${truncateText(text)}"
|
|
||||||
||>Response:
|
|
||||||
"${JSON.stringify(truncateText(responseText))}"`;
|
|
||||||
|
|
||||||
let { prompt: payload } = await this.buildMessages([
|
|
||||||
{
|
|
||||||
text: `Please generate ${titleInstruction}
|
|
||||||
|
|
||||||
${convo}
|
|
||||||
|
|
||||||
||>Title:`,
|
|
||||||
isCreatedByUser: true,
|
|
||||||
author: this.userLabel,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.initializeClient();
|
|
||||||
title = await this.titleChatCompletion(payload, {
|
|
||||||
abortController: new AbortController(),
|
|
||||||
onProgress: () => {},
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('[GoogleClient] There was an issue generating the title', e);
|
|
||||||
}
|
|
||||||
logger.debug(`Title response: ${title}`);
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSaveOptions() {
|
|
||||||
return {
|
|
||||||
endpointType: null,
|
|
||||||
artifacts: this.options.artifacts,
|
|
||||||
promptPrefix: this.options.promptPrefix,
|
|
||||||
maxContextTokens: this.options.maxContextTokens,
|
|
||||||
modelLabel: this.options.modelLabel,
|
|
||||||
iconURL: this.options.iconURL,
|
|
||||||
greeting: this.options.greeting,
|
|
||||||
spec: this.options.spec,
|
|
||||||
...this.modelOptions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
getBuildMessagesOptions() {
|
|
||||||
// logger.debug('GoogleClient doesn\'t use getBuildMessagesOptions');
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendCompletion(payload, opts = {}) {
|
|
||||||
let reply = '';
|
|
||||||
reply = await this.getCompletion(payload, opts);
|
|
||||||
return reply.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
getEncoding() {
|
|
||||||
return 'cl100k_base';
|
|
||||||
}
|
|
||||||
|
|
||||||
async getVertexTokenCount(text) {
|
|
||||||
/** @type {ChatVertexAI} */
|
|
||||||
const client = this.client ?? this.initializeClient();
|
|
||||||
const connection = client.connection;
|
|
||||||
const gAuthClient = connection.client;
|
|
||||||
const tokenEndpoint = `https://${connection._endpoint}/${connection.apiVersion}/projects/${this.project_id}/locations/${connection._location}/publishers/google/models/${connection.model}/:countTokens`;
|
|
||||||
const result = await gAuthClient.request({
|
|
||||||
url: tokenEndpoint,
|
|
||||||
method: 'POST',
|
|
||||||
data: {
|
|
||||||
contents: [{ role: 'user', parts: [{ text }] }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the token count of a given text. It also checks and resets the tokenizers if necessary.
|
|
||||||
* @param {string} text - The text to get the token count for.
|
|
||||||
* @returns {number} The token count of the given text.
|
|
||||||
*/
|
|
||||||
getTokenCount(text) {
|
|
||||||
const encoding = this.getEncoding();
|
|
||||||
return Tokenizer.getTokenCount(text, encoding);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = GoogleClient;
|
|
||||||
|
|
@ -2,10 +2,9 @@ const { z } = require('zod');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { Ollama } = require('ollama');
|
const { Ollama } = require('ollama');
|
||||||
const { sleep } = require('@librechat/agents');
|
const { sleep } = require('@librechat/agents');
|
||||||
const { resolveHeaders } = require('@librechat/api');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { Constants } = require('librechat-data-provider');
|
const { Constants } = require('librechat-data-provider');
|
||||||
const { deriveBaseURL } = require('~/utils');
|
const { resolveHeaders, deriveBaseURL } = require('@librechat/api');
|
||||||
|
|
||||||
const ollamaPayloadSchema = z.object({
|
const ollamaPayloadSchema = z.object({
|
||||||
mirostat: z.number().optional(),
|
mirostat: z.number().optional(),
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +0,0 @@
|
||||||
const tokenSplit = require('./tokenSplit');
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
tokenSplit,
|
|
||||||
};
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
const { TokenTextSplitter } = require('@langchain/textsplitters');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Splits a given text by token chunks, based on the provided parameters for the TokenTextSplitter.
|
|
||||||
* Note: limit or memoize use of this function as its calculation is expensive.
|
|
||||||
*
|
|
||||||
* @param {Object} obj - Configuration object for the text splitting operation.
|
|
||||||
* @param {string} obj.text - The text to be split.
|
|
||||||
* @param {string} [obj.encodingName='cl100k_base'] - Encoding name. Defaults to 'cl100k_base'.
|
|
||||||
* @param {number} [obj.chunkSize=1] - The token size of each chunk. Defaults to 1.
|
|
||||||
* @param {number} [obj.chunkOverlap=0] - The number of chunk elements to be overlapped between adjacent chunks. Defaults to 0.
|
|
||||||
* @param {number} [obj.returnSize] - If specified and not 0, slices the return array from the end by this amount.
|
|
||||||
*
|
|
||||||
* @returns {Promise<Array>} Returns a promise that resolves to an array of text chunks.
|
|
||||||
* If no text is provided, an empty array is returned.
|
|
||||||
* If returnSize is specified and not 0, slices the return array from the end by returnSize.
|
|
||||||
*
|
|
||||||
* @async
|
|
||||||
* @function tokenSplit
|
|
||||||
*/
|
|
||||||
async function tokenSplit({
|
|
||||||
text,
|
|
||||||
encodingName = 'cl100k_base',
|
|
||||||
chunkSize = 1,
|
|
||||||
chunkOverlap = 0,
|
|
||||||
returnSize,
|
|
||||||
}) {
|
|
||||||
if (!text) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const splitter = new TokenTextSplitter({
|
|
||||||
encodingName,
|
|
||||||
chunkSize,
|
|
||||||
chunkOverlap,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!returnSize) {
|
|
||||||
return await splitter.splitText(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
const splitText = await splitter.splitText(text);
|
|
||||||
|
|
||||||
if (returnSize && returnSize > 0 && splitText.length > 0) {
|
|
||||||
return splitText.slice(-Math.abs(returnSize));
|
|
||||||
}
|
|
||||||
|
|
||||||
return splitText;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = tokenSplit;
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
const tokenSplit = require('./tokenSplit');
|
|
||||||
|
|
||||||
describe('tokenSplit', () => {
|
|
||||||
const text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam id.';
|
|
||||||
|
|
||||||
it('returns correct text chunks with provided parameters', async () => {
|
|
||||||
const result = await tokenSplit({
|
|
||||||
text: text,
|
|
||||||
encodingName: 'gpt2',
|
|
||||||
chunkSize: 2,
|
|
||||||
chunkOverlap: 1,
|
|
||||||
returnSize: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual(['it.', '. Null', ' Nullam', 'am id', ' id.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct text chunks with default parameters', async () => {
|
|
||||||
const result = await tokenSplit({ text });
|
|
||||||
expect(result).toEqual([
|
|
||||||
'Lorem',
|
|
||||||
' ipsum',
|
|
||||||
' dolor',
|
|
||||||
' sit',
|
|
||||||
' amet',
|
|
||||||
',',
|
|
||||||
' consectetur',
|
|
||||||
' adipiscing',
|
|
||||||
' elit',
|
|
||||||
'.',
|
|
||||||
' Null',
|
|
||||||
'am',
|
|
||||||
' id',
|
|
||||||
'.',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct text chunks with specific return size', async () => {
|
|
||||||
const result = await tokenSplit({ text, returnSize: 2 });
|
|
||||||
expect(result.length).toEqual(2);
|
|
||||||
expect(result).toEqual([' id', '.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct text chunks with specified chunk size', async () => {
|
|
||||||
const result = await tokenSplit({ text, chunkSize: 10 });
|
|
||||||
expect(result).toEqual([
|
|
||||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
|
|
||||||
' Nullam id.',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty array with no text', async () => {
|
|
||||||
const result = await tokenSplit({ text: '' });
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,13 +1,7 @@
|
||||||
const OpenAIClient = require('./OpenAIClient');
|
|
||||||
const GoogleClient = require('./GoogleClient');
|
|
||||||
const TextStream = require('./TextStream');
|
const TextStream = require('./TextStream');
|
||||||
const AnthropicClient = require('./AnthropicClient');
|
|
||||||
const toolUtils = require('./tools/util');
|
const toolUtils = require('./tools/util');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
OpenAIClient,
|
|
||||||
GoogleClient,
|
|
||||||
TextStream,
|
TextStream,
|
||||||
AnthropicClient,
|
|
||||||
...toolUtils,
|
...toolUtils,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
const { CohereConstants } = require('librechat-data-provider');
|
|
||||||
const { titleInstruction } = require('../prompts/titlePrompts');
|
|
||||||
|
|
||||||
// Mapping OpenAI roles to Cohere roles
|
|
||||||
const roleMap = {
|
|
||||||
user: CohereConstants.ROLE_USER,
|
|
||||||
assistant: CohereConstants.ROLE_CHATBOT,
|
|
||||||
system: CohereConstants.ROLE_SYSTEM, // Recognize and map the system role explicitly
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adjusts an OpenAI ChatCompletionPayload to conform with Cohere's expected chat payload format.
|
|
||||||
* Now includes handling for "system" roles explicitly mentioned.
|
|
||||||
*
|
|
||||||
* @param {Object} options - Object containing the model options.
|
|
||||||
* @param {ChatCompletionPayload} options.modelOptions - The OpenAI model payload options.
|
|
||||||
* @returns {CohereChatStreamRequest} Cohere-compatible chat API payload.
|
|
||||||
*/
|
|
||||||
function createCoherePayload({ modelOptions }) {
|
|
||||||
/** @type {string | undefined} */
|
|
||||||
let preamble;
|
|
||||||
let latestUserMessageContent = '';
|
|
||||||
const {
|
|
||||||
stream,
|
|
||||||
stop,
|
|
||||||
top_p,
|
|
||||||
temperature,
|
|
||||||
frequency_penalty,
|
|
||||||
presence_penalty,
|
|
||||||
max_tokens,
|
|
||||||
messages,
|
|
||||||
model,
|
|
||||||
...rest
|
|
||||||
} = modelOptions;
|
|
||||||
|
|
||||||
// Filter out the latest user message and transform remaining messages to Cohere's chat_history format
|
|
||||||
let chatHistory = messages.reduce((acc, message, index, arr) => {
|
|
||||||
const isLastUserMessage = index === arr.length - 1 && message.role === 'user';
|
|
||||||
|
|
||||||
const messageContent =
|
|
||||||
typeof message.content === 'string'
|
|
||||||
? message.content
|
|
||||||
: message.content.map((part) => (part.type === 'text' ? part.text : '')).join(' ');
|
|
||||||
|
|
||||||
if (isLastUserMessage) {
|
|
||||||
latestUserMessageContent = messageContent;
|
|
||||||
} else {
|
|
||||||
acc.push({
|
|
||||||
role: roleMap[message.role] || CohereConstants.ROLE_USER,
|
|
||||||
message: messageContent,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (
|
|
||||||
chatHistory.length === 1 &&
|
|
||||||
chatHistory[0].role === CohereConstants.ROLE_SYSTEM &&
|
|
||||||
!latestUserMessageContent.length
|
|
||||||
) {
|
|
||||||
const message = chatHistory[0].message;
|
|
||||||
latestUserMessageContent = message.includes(titleInstruction)
|
|
||||||
? CohereConstants.TITLE_MESSAGE
|
|
||||||
: '.';
|
|
||||||
preamble = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
message: latestUserMessageContent,
|
|
||||||
model: model,
|
|
||||||
chatHistory,
|
|
||||||
stream: stream ?? false,
|
|
||||||
temperature: temperature,
|
|
||||||
frequencyPenalty: frequency_penalty,
|
|
||||||
presencePenalty: presence_penalty,
|
|
||||||
maxTokens: max_tokens,
|
|
||||||
stopSequences: stop,
|
|
||||||
preamble,
|
|
||||||
p: top_p,
|
|
||||||
...rest,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = createCoherePayload;
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
const createCoherePayload = require('./createCoherePayload');
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
createCoherePayload,
|
|
||||||
};
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
const { getBasePath } = require('@librechat/api');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `addImages` function corrects any erroneous image URLs in the `responseMessage.text`
|
|
||||||
* and appends image observations from `intermediateSteps` if they are not already present.
|
|
||||||
*
|
|
||||||
* @function
|
|
||||||
* @module addImages
|
|
||||||
*
|
|
||||||
* @param {Array.<Object>} intermediateSteps - An array of objects, each containing an observation.
|
|
||||||
* @param {Object} responseMessage - An object containing the text property which might have image URLs.
|
|
||||||
*
|
|
||||||
* @property {string} intermediateSteps[].observation - The observation string which might contain an image markdown.
|
|
||||||
* @property {string} responseMessage.text - The text which might contain image URLs.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
*
|
|
||||||
* const intermediateSteps = [
|
|
||||||
* { observation: '' }
|
|
||||||
* ];
|
|
||||||
* const responseMessage = { text: 'Some text with ' };
|
|
||||||
*
|
|
||||||
* addImages(intermediateSteps, responseMessage);
|
|
||||||
*
|
|
||||||
* logger.debug(responseMessage.text);
|
|
||||||
* // Outputs: 'Some text with \n'
|
|
||||||
*
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
function addImages(intermediateSteps, responseMessage) {
|
|
||||||
if (!intermediateSteps || !responseMessage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const basePath = getBasePath();
|
|
||||||
|
|
||||||
// Correct any erroneous URLs in the responseMessage.text first
|
|
||||||
intermediateSteps.forEach((step) => {
|
|
||||||
const { observation } = step;
|
|
||||||
if (!observation || !observation.includes('![')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = observation.match(/\/images\/.*\.\w*/);
|
|
||||||
if (!match) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const essentialImagePath = match[0];
|
|
||||||
const fullImagePath = `${basePath}${essentialImagePath}`;
|
|
||||||
|
|
||||||
const regex = /!\[.*?\]\((.*?)\)/g;
|
|
||||||
let matchErroneous;
|
|
||||||
while ((matchErroneous = regex.exec(responseMessage.text)) !== null) {
|
|
||||||
if (matchErroneous[1] && !matchErroneous[1].startsWith(`${basePath}/images/`)) {
|
|
||||||
// Replace with the full path including base path
|
|
||||||
responseMessage.text = responseMessage.text.replace(matchErroneous[1], fullImagePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Now, check if the responseMessage already includes the correct image file path and append if not
|
|
||||||
intermediateSteps.forEach((step) => {
|
|
||||||
const { observation } = step;
|
|
||||||
if (!observation || !observation.includes('![')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const observedImagePath = observation.match(/!\[[^(]*\]\([^)]*\)/g);
|
|
||||||
if (observedImagePath) {
|
|
||||||
// Fix the image path to include base path if it doesn't already
|
|
||||||
let imageMarkdown = observedImagePath[0];
|
|
||||||
const urlMatch = imageMarkdown.match(/\(([^)]+)\)/);
|
|
||||||
if (
|
|
||||||
urlMatch &&
|
|
||||||
urlMatch[1] &&
|
|
||||||
!urlMatch[1].startsWith(`${basePath}/images/`) &&
|
|
||||||
urlMatch[1].startsWith('/images/')
|
|
||||||
) {
|
|
||||||
imageMarkdown = imageMarkdown.replace(urlMatch[1], `${basePath}${urlMatch[1]}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!responseMessage.text.includes(imageMarkdown)) {
|
|
||||||
responseMessage.text += '\n' + imageMarkdown;
|
|
||||||
logger.debug('[addImages] added image from intermediateSteps:', imageMarkdown);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = addImages;
|
|
||||||
|
|
@ -1,246 +0,0 @@
|
||||||
let addImages = require('./addImages');
|
|
||||||
|
|
||||||
describe('addImages', () => {
|
|
||||||
let intermediateSteps;
|
|
||||||
let responseMessage;
|
|
||||||
let options;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
intermediateSteps = [];
|
|
||||||
responseMessage = { text: '' };
|
|
||||||
options = { debug: false };
|
|
||||||
this.options = options;
|
|
||||||
addImages = addImages.bind(this);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle null or undefined parameters', () => {
|
|
||||||
addImages(null, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe('');
|
|
||||||
|
|
||||||
addImages(intermediateSteps, null);
|
|
||||||
expect(responseMessage.text).toBe('');
|
|
||||||
|
|
||||||
addImages(null, null);
|
|
||||||
expect(responseMessage.text).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should append correct image markdown if not present in responseMessage', () => {
|
|
||||||
intermediateSteps.push({ observation: '' });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe('\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not append image markdown if already present in responseMessage', () => {
|
|
||||||
responseMessage.text = '';
|
|
||||||
intermediateSteps.push({ observation: '' });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should correct and append image markdown with erroneous URL', () => {
|
|
||||||
responseMessage.text = '';
|
|
||||||
intermediateSteps.push({ observation: '' });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should correct multiple erroneous URLs in responseMessage', () => {
|
|
||||||
responseMessage.text =
|
|
||||||
' ';
|
|
||||||
intermediateSteps.push({ observation: '' });
|
|
||||||
intermediateSteps.push({ observation: '' });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe(' ');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not append non-image markdown observations', () => {
|
|
||||||
intermediateSteps.push({ observation: '[desc](/images/test.png)' });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle multiple observations', () => {
|
|
||||||
intermediateSteps.push({ observation: '' });
|
|
||||||
intermediateSteps.push({ observation: '' });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe('\n\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not append if observation does not contain image markdown', () => {
|
|
||||||
intermediateSteps.push({ observation: 'This is a test observation without image markdown.' });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should append correctly from a real scenario', () => {
|
|
||||||
responseMessage.text =
|
|
||||||
"Here is the generated image based on your request. It depicts a surreal landscape filled with floating musical notes. The style is impressionistic, with vibrant sunset hues dominating the scene. At the center, there's a silhouette of a grand piano, adding a dreamy emotion to the overall image. This could serve as a unique and creative music album cover. Would you like to make any changes or generate another image?";
|
|
||||||
const originalText = responseMessage.text;
|
|
||||||
const imageMarkdown = '';
|
|
||||||
intermediateSteps.push({ observation: imageMarkdown });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe(`${originalText}\n${imageMarkdown}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should extract only image markdowns when there is text between them', () => {
|
|
||||||
const markdownWithTextBetweenImages = `
|
|
||||||

|
|
||||||
Some text between images that should not be included.
|
|
||||||

|
|
||||||
More text that should be ignored.
|
|
||||||

|
|
||||||
`;
|
|
||||||
intermediateSteps.push({ observation: markdownWithTextBetweenImages });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe('\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should only return the first image when multiple images are present', () => {
|
|
||||||
const markdownWithMultipleImages = `
|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
`;
|
|
||||||
intermediateSteps.push({ observation: markdownWithMultipleImages });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe('\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not include any text or metadata surrounding the image markdown', () => {
|
|
||||||
const markdownWithMetadata = `
|
|
||||||
Title: Test Document
|
|
||||||
Author: John Doe
|
|
||||||

|
|
||||||
Some content after the image.
|
|
||||||
Vector values: [0.1, 0.2, 0.3]
|
|
||||||
`;
|
|
||||||
intermediateSteps.push({ observation: markdownWithMetadata });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe('\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle complex markdown with multiple images and only return the first one', () => {
|
|
||||||
const complexMarkdown = `
|
|
||||||
# Document Title
|
|
||||||
|
|
||||||
## Section 1
|
|
||||||
Here's some text with an embedded image:
|
|
||||||

|
|
||||||
|
|
||||||
## Section 2
|
|
||||||
More text here...
|
|
||||||

|
|
||||||
|
|
||||||
### Subsection
|
|
||||||
Even more content
|
|
||||||

|
|
||||||
`;
|
|
||||||
intermediateSteps.push({ observation: complexMarkdown });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe('\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('basePath functionality', () => {
|
|
||||||
let originalDomainClient;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
originalDomainClient = process.env.DOMAIN_CLIENT;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env.DOMAIN_CLIENT = originalDomainClient;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should prepend base path to image URLs when DOMAIN_CLIENT is set', () => {
|
|
||||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
|
||||||
intermediateSteps.push({ observation: '' });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe('\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not prepend base path when image URL already has base path', () => {
|
|
||||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
|
||||||
intermediateSteps.push({ observation: '' });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe('\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should correct erroneous URLs with base path', () => {
|
|
||||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
|
||||||
responseMessage.text = '';
|
|
||||||
intermediateSteps.push({ observation: '' });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty base path (root deployment)', () => {
|
|
||||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/';
|
|
||||||
intermediateSteps.push({ observation: '' });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe('\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle missing DOMAIN_CLIENT', () => {
|
|
||||||
delete process.env.DOMAIN_CLIENT;
|
|
||||||
intermediateSteps.push({ observation: '' });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe('\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle observation without image path match', () => {
|
|
||||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
|
||||||
intermediateSteps.push({ observation: '' });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe('\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle nested subdirectories in base path', () => {
|
|
||||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat';
|
|
||||||
intermediateSteps.push({ observation: '' });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe('\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle multiple observations with mixed base path scenarios', () => {
|
|
||||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
|
||||||
intermediateSteps.push({ observation: '' });
|
|
||||||
intermediateSteps.push({ observation: '' });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe(
|
|
||||||
'\n\n',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle complex markdown with base path', () => {
|
|
||||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
|
||||||
const complexMarkdown = `
|
|
||||||
# Document Title
|
|
||||||

|
|
||||||
Some text between images
|
|
||||||

|
|
||||||
`;
|
|
||||||
intermediateSteps.push({ observation: complexMarkdown });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe('\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle URLs that are already absolute', () => {
|
|
||||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
|
||||||
intermediateSteps.push({ observation: '' });
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe('\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle data URLs', () => {
|
|
||||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
|
||||||
intermediateSteps.push({
|
|
||||||
observation:
|
|
||||||
'',
|
|
||||||
});
|
|
||||||
addImages(intermediateSteps, responseMessage);
|
|
||||||
expect(responseMessage.text).toBe(
|
|
||||||
'\n',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
const { instructions, imageInstructions, errorInstructions } = require('../prompts');
|
|
||||||
|
|
||||||
function getActions(actions = [], functionsAgent = false) {
|
|
||||||
let output = 'Internal thoughts & actions taken:\n"';
|
|
||||||
|
|
||||||
if (actions[0]?.action && functionsAgent) {
|
|
||||||
actions = actions.map((step) => ({
|
|
||||||
log: `Action: ${step.action?.tool || ''}\nInput: ${
|
|
||||||
JSON.stringify(step.action?.toolInput) || ''
|
|
||||||
}\nObservation: ${step.observation}`,
|
|
||||||
}));
|
|
||||||
} else if (actions[0]?.action) {
|
|
||||||
actions = actions.map((step) => ({
|
|
||||||
log: `${step.action.log}\nObservation: ${step.observation}`,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
actions.forEach((actionObj, index) => {
|
|
||||||
output += `${actionObj.log}`;
|
|
||||||
if (index < actions.length - 1) {
|
|
||||||
output += '\n';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return output + '"';
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildErrorInput({ message, errorMessage, actions, functionsAgent }) {
|
|
||||||
const log = errorMessage.includes('Could not parse LLM output:')
|
|
||||||
? `A formatting error occurred with your response to the human's last message. You didn't follow the formatting instructions. Remember to ${instructions}`
|
|
||||||
: `You encountered an error while replying to the human's last message. Attempt to answer again or admit an answer cannot be given.\nError: ${errorMessage}`;
|
|
||||||
|
|
||||||
return `
|
|
||||||
${log}
|
|
||||||
|
|
||||||
${getActions(actions, functionsAgent)}
|
|
||||||
|
|
||||||
Human's last message: ${message}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPromptPrefix({ result, message, functionsAgent }) {
|
|
||||||
if ((result.output && result.output.includes('N/A')) || result.output === undefined) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
result?.intermediateSteps?.length === 1 &&
|
|
||||||
result?.intermediateSteps[0]?.action?.toolInput === 'N/A'
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const internalActions =
|
|
||||||
result?.intermediateSteps?.length > 0
|
|
||||||
? getActions(result.intermediateSteps, functionsAgent)
|
|
||||||
: 'Internal Actions Taken: None';
|
|
||||||
|
|
||||||
const toolBasedInstructions = internalActions.toLowerCase().includes('image')
|
|
||||||
? imageInstructions
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const errorMessage = result.errorMessage ? `${errorInstructions} ${result.errorMessage}\n` : '';
|
|
||||||
|
|
||||||
const preliminaryAnswer =
|
|
||||||
result.output?.length > 0 ? `Preliminary Answer: "${result.output.trim()}"` : '';
|
|
||||||
const prefix = preliminaryAnswer
|
|
||||||
? 'review and improve the answer you generated using plugins in response to the User Message below. The user hasn\'t seen your answer or thoughts yet.'
|
|
||||||
: 'respond to the User Message below based on your preliminary thoughts & actions.';
|
|
||||||
|
|
||||||
return `As a helpful AI Assistant, ${prefix}${errorMessage}\n${internalActions}
|
|
||||||
${preliminaryAnswer}
|
|
||||||
Reply conversationally to the User based on your ${
|
|
||||||
preliminaryAnswer ? 'preliminary answer, ' : ''
|
|
||||||
}internal actions, thoughts, and observations, making improvements wherever possible, but do not modify URLs.
|
|
||||||
${
|
|
||||||
preliminaryAnswer
|
|
||||||
? ''
|
|
||||||
: '\nIf there is an incomplete thought or action, you are expected to complete it in your response now.\n'
|
|
||||||
}You must cite sources if you are using any web links. ${toolBasedInstructions}
|
|
||||||
Only respond with your conversational reply to the following User Message:
|
|
||||||
"${message}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
buildErrorInput,
|
|
||||||
buildPromptPrefix,
|
|
||||||
};
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
const addImages = require('./addImages');
|
|
||||||
const handleOutputs = require('./handleOutputs');
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
addImages,
|
|
||||||
...handleOutputs,
|
|
||||||
};
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
// Escaping curly braces is necessary for LangChain to correctly process the prompt
|
|
||||||
function escapeBraces(str) {
|
|
||||||
return str
|
|
||||||
.replace(/({{2,})|(}{2,})/g, (match) => `${match[0]}`)
|
|
||||||
.replace(/{|}/g, (match) => `${match}${match}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSnippet(text) {
|
|
||||||
let limit = 50;
|
|
||||||
let splitText = escapeBraces(text).split(' ');
|
|
||||||
|
|
||||||
if (splitText.length === 1 && splitText[0].length > limit) {
|
|
||||||
return splitText[0].substring(0, limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = '';
|
|
||||||
let spaceCount = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < splitText.length; i++) {
|
|
||||||
if (result.length + splitText[i].length <= limit) {
|
|
||||||
result += splitText[i] + ' ';
|
|
||||||
spaceCount++;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (spaceCount == 10) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
escapeBraces,
|
|
||||||
getSnippet,
|
|
||||||
};
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
const formatMessages = require('./formatMessages');
|
const formatMessages = require('./formatMessages');
|
||||||
const summaryPrompts = require('./summaryPrompts');
|
const summaryPrompts = require('./summaryPrompts');
|
||||||
const handleInputs = require('./handleInputs');
|
|
||||||
const instructions = require('./instructions');
|
|
||||||
const truncate = require('./truncate');
|
const truncate = require('./truncate');
|
||||||
const createVisionPrompt = require('./createVisionPrompt');
|
const createVisionPrompt = require('./createVisionPrompt');
|
||||||
const createContextHandlers = require('./createContextHandlers');
|
const createContextHandlers = require('./createContextHandlers');
|
||||||
|
|
@ -9,8 +7,6 @@ const createContextHandlers = require('./createContextHandlers');
|
||||||
module.exports = {
|
module.exports = {
|
||||||
...formatMessages,
|
...formatMessages,
|
||||||
...summaryPrompts,
|
...summaryPrompts,
|
||||||
...handleInputs,
|
|
||||||
...instructions,
|
|
||||||
...truncate,
|
...truncate,
|
||||||
createVisionPrompt,
|
createVisionPrompt,
|
||||||
createContextHandlers,
|
createContextHandlers,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
instructions:
|
|
||||||
'Remember, all your responses MUST be in the format described. Do not respond unless it\'s in the format described, using the structure of Action, Action Input, etc.',
|
|
||||||
errorInstructions:
|
|
||||||
'\nYou encountered an error in attempting a response. The user is not aware of the error so you shouldn\'t mention it.\nReview the actions taken carefully in case there is a partial or complete answer within them.\nError Message:',
|
|
||||||
imageInstructions:
|
|
||||||
'You must include the exact image paths from above, formatted in Markdown syntax: ',
|
|
||||||
completionInstructions:
|
|
||||||
'Instructions:\nYou are ChatGPT, a large language model trained by OpenAI. Respond conversationally.\nCurrent date:',
|
|
||||||
};
|
|
||||||
|
|
@ -18,17 +18,17 @@ function generateShadcnPrompt(options) {
|
||||||
Here are the components that are available, along with how to import them, and how to use them:
|
Here are the components that are available, along with how to import them, and how to use them:
|
||||||
|
|
||||||
${Object.values(components)
|
${Object.values(components)
|
||||||
.map((component) => {
|
.map((component) => {
|
||||||
if (useXML) {
|
if (useXML) {
|
||||||
return dedent`
|
return dedent`
|
||||||
<component>
|
<component>
|
||||||
<name>${component.componentName}</name>
|
<name>${component.componentName}</name>
|
||||||
<import-instructions>${component.importDocs}</import-instructions>
|
<import-instructions>${component.importDocs}</import-instructions>
|
||||||
<usage-instructions>${component.usageDocs}</usage-instructions>
|
<usage-instructions>${component.usageDocs}</usage-instructions>
|
||||||
</component>
|
</component>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
return dedent`
|
return dedent`
|
||||||
# ${component.componentName}
|
# ${component.componentName}
|
||||||
|
|
||||||
## Import Instructions
|
## Import Instructions
|
||||||
|
|
@ -37,9 +37,9 @@ function generateShadcnPrompt(options) {
|
||||||
## Usage Instructions
|
## Usage Instructions
|
||||||
${component.usageDocs}
|
${component.usageDocs}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.join('\n\n')}
|
.join('\n\n')}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return systemPrompt;
|
return systemPrompt;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,630 +0,0 @@
|
||||||
jest.mock('~/cache/getLogStores');
|
|
||||||
require('dotenv').config();
|
|
||||||
const { fetchEventSource } = require('@waylaidwanderer/fetch-event-source');
|
|
||||||
const getLogStores = require('~/cache/getLogStores');
|
|
||||||
const OpenAIClient = require('../OpenAIClient');
|
|
||||||
jest.mock('meilisearch');
|
|
||||||
|
|
||||||
jest.mock('~/db/connect');
|
|
||||||
jest.mock('~/models', () => ({
|
|
||||||
User: jest.fn(),
|
|
||||||
Key: jest.fn(),
|
|
||||||
Session: jest.fn(),
|
|
||||||
Balance: jest.fn(),
|
|
||||||
Transaction: jest.fn(),
|
|
||||||
getMessages: jest.fn().mockResolvedValue([]),
|
|
||||||
saveMessage: jest.fn(),
|
|
||||||
updateMessage: jest.fn(),
|
|
||||||
deleteMessagesSince: jest.fn(),
|
|
||||||
deleteMessages: jest.fn(),
|
|
||||||
getConvoTitle: jest.fn(),
|
|
||||||
getConvo: jest.fn(),
|
|
||||||
saveConvo: jest.fn(),
|
|
||||||
deleteConvos: jest.fn(),
|
|
||||||
getPreset: jest.fn(),
|
|
||||||
getPresets: jest.fn(),
|
|
||||||
savePreset: jest.fn(),
|
|
||||||
deletePresets: jest.fn(),
|
|
||||||
findFileById: jest.fn(),
|
|
||||||
createFile: jest.fn(),
|
|
||||||
updateFile: jest.fn(),
|
|
||||||
deleteFile: jest.fn(),
|
|
||||||
deleteFiles: jest.fn(),
|
|
||||||
getFiles: jest.fn(),
|
|
||||||
updateFileUsage: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Import the actual module but mock specific parts
|
|
||||||
const agents = jest.requireActual('@librechat/agents');
|
|
||||||
const { CustomOpenAIClient } = agents;
|
|
||||||
|
|
||||||
// Also mock ChatOpenAI to prevent real API calls
|
|
||||||
agents.ChatOpenAI = jest.fn().mockImplementation(() => {
|
|
||||||
return {};
|
|
||||||
});
|
|
||||||
agents.AzureChatOpenAI = jest.fn().mockImplementation(() => {
|
|
||||||
return {};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock only the CustomOpenAIClient constructor
|
|
||||||
jest.spyOn(CustomOpenAIClient, 'constructor').mockImplementation(function (...options) {
|
|
||||||
return new CustomOpenAIClient(...options);
|
|
||||||
});
|
|
||||||
|
|
||||||
const finalChatCompletion = jest.fn().mockResolvedValue({
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
message: { role: 'assistant', content: 'Mock message content' },
|
|
||||||
finish_reason: 'Mock finish reason',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const stream = jest.fn().mockImplementation(() => {
|
|
||||||
let isDone = false;
|
|
||||||
let isError = false;
|
|
||||||
let errorCallback = null;
|
|
||||||
|
|
||||||
const onEventHandlers = {
|
|
||||||
abort: () => {
|
|
||||||
// Mock abort behavior
|
|
||||||
},
|
|
||||||
error: (callback) => {
|
|
||||||
errorCallback = callback; // Save the error callback for later use
|
|
||||||
},
|
|
||||||
finalMessage: (callback) => {
|
|
||||||
callback({ role: 'assistant', content: 'Mock Response' });
|
|
||||||
isDone = true; // Set stream to done
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockStream = {
|
|
||||||
on: jest.fn((event, callback) => {
|
|
||||||
if (onEventHandlers[event]) {
|
|
||||||
onEventHandlers[event](callback);
|
|
||||||
}
|
|
||||||
return mockStream;
|
|
||||||
}),
|
|
||||||
finalChatCompletion,
|
|
||||||
controller: { abort: jest.fn() },
|
|
||||||
triggerError: () => {
|
|
||||||
isError = true;
|
|
||||||
if (errorCallback) {
|
|
||||||
errorCallback(new Error('Mock error'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[Symbol.asyncIterator]: () => {
|
|
||||||
return {
|
|
||||||
next: () => {
|
|
||||||
if (isError) {
|
|
||||||
return Promise.reject(new Error('Mock error'));
|
|
||||||
}
|
|
||||||
if (isDone) {
|
|
||||||
return Promise.resolve({ done: true });
|
|
||||||
}
|
|
||||||
const chunk = { choices: [{ delta: { content: 'Mock chunk' } }] };
|
|
||||||
return Promise.resolve({ value: chunk, done: false });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return mockStream;
|
|
||||||
});
|
|
||||||
|
|
||||||
const create = jest.fn().mockResolvedValue({
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
message: { content: 'Mock message content' },
|
|
||||||
finish_reason: 'Mock finish reason',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock the implementation of CustomOpenAIClient instances
|
|
||||||
jest.spyOn(CustomOpenAIClient.prototype, 'constructor').mockImplementation(function () {
|
|
||||||
return this;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a mock for the CustomOpenAIClient class
|
|
||||||
const mockCustomOpenAIClient = jest.fn().mockImplementation(() => ({
|
|
||||||
beta: {
|
|
||||||
chat: {
|
|
||||||
completions: {
|
|
||||||
stream,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
chat: {
|
|
||||||
completions: {
|
|
||||||
create,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
CustomOpenAIClient.mockImplementation = mockCustomOpenAIClient;
|
|
||||||
|
|
||||||
describe('OpenAIClient', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
const mockCache = {
|
|
||||||
get: jest.fn().mockResolvedValue({}),
|
|
||||||
set: jest.fn(),
|
|
||||||
};
|
|
||||||
getLogStores.mockReturnValue(mockCache);
|
|
||||||
});
|
|
||||||
let client;
|
|
||||||
const model = 'gpt-4';
|
|
||||||
const parentMessageId = '1';
|
|
||||||
const messages = [
|
|
||||||
{ role: 'user', sender: 'User', text: 'Hello', messageId: parentMessageId },
|
|
||||||
{ role: 'assistant', sender: 'Assistant', text: 'Hi', messageId: '2' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const defaultOptions = {
|
|
||||||
// debug: true,
|
|
||||||
req: {},
|
|
||||||
openaiApiKey: 'new-api-key',
|
|
||||||
modelOptions: {
|
|
||||||
model,
|
|
||||||
temperature: 0.7,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultAzureOptions = {
|
|
||||||
azureOpenAIApiInstanceName: 'your-instance-name',
|
|
||||||
azureOpenAIApiDeploymentName: 'your-deployment-name',
|
|
||||||
azureOpenAIApiVersion: '2020-07-01-preview',
|
|
||||||
};
|
|
||||||
|
|
||||||
let originalWarn;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
originalWarn = console.warn;
|
|
||||||
console.warn = jest.fn();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
console.warn = originalWarn;
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
console.warn.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const options = { ...defaultOptions };
|
|
||||||
client = new OpenAIClient('test-api-key', options);
|
|
||||||
client.summarizeMessages = jest.fn().mockResolvedValue({
|
|
||||||
role: 'assistant',
|
|
||||||
content: 'Refined answer',
|
|
||||||
tokenCount: 30,
|
|
||||||
});
|
|
||||||
client.buildPrompt = jest
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue({ prompt: messages.map((m) => m.text).join('\n') });
|
|
||||||
client.getMessages = jest.fn().mockResolvedValue([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('setOptions', () => {
|
|
||||||
it('should set the options correctly', () => {
|
|
||||||
expect(client.apiKey).toBe('new-api-key');
|
|
||||||
expect(client.modelOptions.model).toBe(model);
|
|
||||||
expect(client.modelOptions.temperature).toBe(0.7);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set FORCE_PROMPT based on OPENAI_FORCE_PROMPT or reverseProxyUrl', () => {
|
|
||||||
process.env.OPENAI_FORCE_PROMPT = 'true';
|
|
||||||
client.setOptions({});
|
|
||||||
expect(client.FORCE_PROMPT).toBe(true);
|
|
||||||
delete process.env.OPENAI_FORCE_PROMPT; // Cleanup
|
|
||||||
client.FORCE_PROMPT = undefined;
|
|
||||||
|
|
||||||
client.setOptions({ reverseProxyUrl: 'https://example.com/completions' });
|
|
||||||
expect(client.FORCE_PROMPT).toBe(true);
|
|
||||||
client.FORCE_PROMPT = undefined;
|
|
||||||
|
|
||||||
client.setOptions({ reverseProxyUrl: 'https://example.com/chat' });
|
|
||||||
expect(client.FORCE_PROMPT).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set isChatCompletion based on useOpenRouter, reverseProxyUrl, or model', () => {
|
|
||||||
client.setOptions({ reverseProxyUrl: null });
|
|
||||||
// true by default since default model will be gpt-4o-mini
|
|
||||||
expect(client.isChatCompletion).toBe(true);
|
|
||||||
client.isChatCompletion = undefined;
|
|
||||||
|
|
||||||
// false because completions url will force prompt payload
|
|
||||||
client.setOptions({ reverseProxyUrl: 'https://example.com/completions' });
|
|
||||||
expect(client.isChatCompletion).toBe(false);
|
|
||||||
client.isChatCompletion = undefined;
|
|
||||||
|
|
||||||
client.setOptions({ modelOptions: { model: 'gpt-4o-mini' }, reverseProxyUrl: null });
|
|
||||||
expect(client.isChatCompletion).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set completionsUrl and langchainProxy based on reverseProxyUrl', () => {
|
|
||||||
client.setOptions({ reverseProxyUrl: 'https://localhost:8080/v1/chat/completions' });
|
|
||||||
expect(client.completionsUrl).toBe('https://localhost:8080/v1/chat/completions');
|
|
||||||
expect(client.langchainProxy).toBe('https://localhost:8080/v1');
|
|
||||||
|
|
||||||
client.setOptions({ reverseProxyUrl: 'https://example.com/completions' });
|
|
||||||
expect(client.completionsUrl).toBe('https://example.com/completions');
|
|
||||||
expect(client.langchainProxy).toBe('https://example.com/completions');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('setOptions with Simplified Azure Integration', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
delete process.env.AZURE_OPENAI_DEFAULT_MODEL;
|
|
||||||
delete process.env.AZURE_USE_MODEL_AS_DEPLOYMENT_NAME;
|
|
||||||
});
|
|
||||||
|
|
||||||
const azureOpenAIApiInstanceName = 'test-instance';
|
|
||||||
const azureOpenAIApiDeploymentName = 'test-deployment';
|
|
||||||
const azureOpenAIApiVersion = '2020-07-01-preview';
|
|
||||||
|
|
||||||
const createOptions = (model) => ({
|
|
||||||
modelOptions: { model },
|
|
||||||
azure: {
|
|
||||||
azureOpenAIApiInstanceName,
|
|
||||||
azureOpenAIApiDeploymentName,
|
|
||||||
azureOpenAIApiVersion,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set model from AZURE_OPENAI_DEFAULT_MODEL when Azure is enabled', () => {
|
|
||||||
process.env.AZURE_OPENAI_DEFAULT_MODEL = 'gpt-4-azure';
|
|
||||||
const options = createOptions('test');
|
|
||||||
client.azure = options.azure;
|
|
||||||
client.setOptions(options);
|
|
||||||
expect(client.modelOptions.model).toBe('gpt-4-azure');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not change model if Azure is not enabled', () => {
|
|
||||||
process.env.AZURE_OPENAI_DEFAULT_MODEL = 'gpt-4-azure';
|
|
||||||
const originalModel = 'test';
|
|
||||||
client.azure = false;
|
|
||||||
client.setOptions(createOptions('test'));
|
|
||||||
expect(client.modelOptions.model).toBe(originalModel);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not change model if AZURE_OPENAI_DEFAULT_MODEL is not set and model is passed', () => {
|
|
||||||
const originalModel = 'GROK-LLM';
|
|
||||||
const options = createOptions(originalModel);
|
|
||||||
client.azure = options.azure;
|
|
||||||
client.setOptions(options);
|
|
||||||
expect(client.modelOptions.model).toBe(originalModel);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should change model if AZURE_OPENAI_DEFAULT_MODEL is set and model is passed', () => {
|
|
||||||
process.env.AZURE_OPENAI_DEFAULT_MODEL = 'gpt-4-azure';
|
|
||||||
const originalModel = 'GROK-LLM';
|
|
||||||
const options = createOptions(originalModel);
|
|
||||||
client.azure = options.azure;
|
|
||||||
client.setOptions(options);
|
|
||||||
expect(client.modelOptions.model).toBe(process.env.AZURE_OPENAI_DEFAULT_MODEL);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include model in deployment name if AZURE_USE_MODEL_AS_DEPLOYMENT_NAME is set', () => {
|
|
||||||
process.env.AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = 'true';
|
|
||||||
const model = 'gpt-4-azure';
|
|
||||||
|
|
||||||
const AzureClient = new OpenAIClient('test-api-key', createOptions(model));
|
|
||||||
|
|
||||||
const expectedValue = `https://${azureOpenAIApiInstanceName}.openai.azure.com/openai/deployments/${model}/chat/completions?api-version=${azureOpenAIApiVersion}`;
|
|
||||||
|
|
||||||
expect(AzureClient.modelOptions.model).toBe(model);
|
|
||||||
expect(AzureClient.azureEndpoint).toBe(expectedValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include model in deployment name if AZURE_USE_MODEL_AS_DEPLOYMENT_NAME and default model is set', () => {
|
|
||||||
const defaultModel = 'gpt-4-azure';
|
|
||||||
process.env.AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = 'true';
|
|
||||||
process.env.AZURE_OPENAI_DEFAULT_MODEL = defaultModel;
|
|
||||||
const model = 'gpt-4-this-is-a-test-model-name';
|
|
||||||
|
|
||||||
const AzureClient = new OpenAIClient('test-api-key', createOptions(model));
|
|
||||||
|
|
||||||
const expectedValue = `https://${azureOpenAIApiInstanceName}.openai.azure.com/openai/deployments/${model}/chat/completions?api-version=${azureOpenAIApiVersion}`;
|
|
||||||
|
|
||||||
expect(AzureClient.modelOptions.model).toBe(defaultModel);
|
|
||||||
expect(AzureClient.azureEndpoint).toBe(expectedValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not include model in deployment name if AZURE_USE_MODEL_AS_DEPLOYMENT_NAME is not set', () => {
|
|
||||||
const model = 'gpt-4-azure';
|
|
||||||
|
|
||||||
const AzureClient = new OpenAIClient('test-api-key', createOptions(model));
|
|
||||||
|
|
||||||
const expectedValue = `https://${azureOpenAIApiInstanceName}.openai.azure.com/openai/deployments/${azureOpenAIApiDeploymentName}/chat/completions?api-version=${azureOpenAIApiVersion}`;
|
|
||||||
|
|
||||||
expect(AzureClient.modelOptions.model).toBe(model);
|
|
||||||
expect(AzureClient.azureEndpoint).toBe(expectedValue);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getTokenCount', () => {
|
|
||||||
it('should return the correct token count', () => {
|
|
||||||
const count = client.getTokenCount('Hello, world!');
|
|
||||||
expect(count).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getSaveOptions', () => {
|
|
||||||
it('should return the correct save options', () => {
|
|
||||||
const options = client.getSaveOptions();
|
|
||||||
expect(options).toHaveProperty('chatGptLabel');
|
|
||||||
expect(options).toHaveProperty('modelLabel');
|
|
||||||
expect(options).toHaveProperty('promptPrefix');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getBuildMessagesOptions', () => {
|
|
||||||
it('should return the correct build messages options', () => {
|
|
||||||
const options = client.getBuildMessagesOptions({ promptPrefix: 'Hello' });
|
|
||||||
expect(options).toHaveProperty('isChatCompletion');
|
|
||||||
expect(options).toHaveProperty('promptPrefix');
|
|
||||||
expect(options.promptPrefix).toBe('Hello');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('buildMessages', () => {
|
|
||||||
it('should build messages correctly for chat completion', async () => {
|
|
||||||
const result = await client.buildMessages(messages, parentMessageId, {
|
|
||||||
isChatCompletion: true,
|
|
||||||
});
|
|
||||||
expect(result).toHaveProperty('prompt');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should build messages correctly for non-chat completion', async () => {
|
|
||||||
const result = await client.buildMessages(messages, parentMessageId, {
|
|
||||||
isChatCompletion: false,
|
|
||||||
});
|
|
||||||
expect(result).toHaveProperty('prompt');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should build messages correctly with a promptPrefix', async () => {
|
|
||||||
const result = await client.buildMessages(messages, parentMessageId, {
|
|
||||||
isChatCompletion: true,
|
|
||||||
promptPrefix: 'Test Prefix',
|
|
||||||
});
|
|
||||||
expect(result).toHaveProperty('prompt');
|
|
||||||
const instructions = result.prompt.find((item) => item.content.includes('Test Prefix'));
|
|
||||||
expect(instructions).toBeDefined();
|
|
||||||
expect(instructions.content).toContain('Test Prefix');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle context strategy correctly', async () => {
|
|
||||||
client.contextStrategy = 'summarize';
|
|
||||||
const result = await client.buildMessages(messages, parentMessageId, {
|
|
||||||
isChatCompletion: true,
|
|
||||||
});
|
|
||||||
expect(result).toHaveProperty('prompt');
|
|
||||||
expect(result).toHaveProperty('tokenCountMap');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should assign name property for user messages when options.name is set', async () => {
|
|
||||||
client.options.name = 'Test User';
|
|
||||||
const result = await client.buildMessages(messages, parentMessageId, {
|
|
||||||
isChatCompletion: true,
|
|
||||||
});
|
|
||||||
const hasUserWithName = result.prompt.some(
|
|
||||||
(item) => item.role === 'user' && item.name === 'Test_User',
|
|
||||||
);
|
|
||||||
expect(hasUserWithName).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle promptPrefix from options when promptPrefix argument is not provided', async () => {
|
|
||||||
client.options.promptPrefix = 'Test Prefix from options';
|
|
||||||
const result = await client.buildMessages(messages, parentMessageId, {
|
|
||||||
isChatCompletion: true,
|
|
||||||
});
|
|
||||||
const instructions = result.prompt.find((item) =>
|
|
||||||
item.content.includes('Test Prefix from options'),
|
|
||||||
);
|
|
||||||
expect(instructions.content).toContain('Test Prefix from options');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle case when neither promptPrefix argument nor options.promptPrefix is set', async () => {
|
|
||||||
const result = await client.buildMessages(messages, parentMessageId, {
|
|
||||||
isChatCompletion: true,
|
|
||||||
});
|
|
||||||
const instructions = result.prompt.find((item) => item.content.includes('Test Prefix'));
|
|
||||||
expect(instructions).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle case when getMessagesForConversation returns null or an empty array', async () => {
|
|
||||||
const messages = [];
|
|
||||||
const result = await client.buildMessages(messages, parentMessageId, {
|
|
||||||
isChatCompletion: true,
|
|
||||||
});
|
|
||||||
expect(result.prompt).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getTokenCountForMessage', () => {
|
|
||||||
const example_messages = [
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content:
|
|
||||||
'You are a helpful, pattern-following assistant that translates corporate jargon into plain English.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
name: 'example_user',
|
|
||||||
content: 'New synergies will help drive top-line growth.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
name: 'example_assistant',
|
|
||||||
content: 'Things working well together will increase revenue.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
name: 'example_user',
|
|
||||||
content:
|
|
||||||
"Let's circle back when we have more bandwidth to touch base on opportunities for increased leverage.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
name: 'example_assistant',
|
|
||||||
content: "Let's talk later when we're less busy about how to do better.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content:
|
|
||||||
"This late pivot means we don't have time to boil the ocean for the client deliverable.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const testCases = [
|
|
||||||
{ model: 'gpt-3.5-turbo-0301', expected: 127 },
|
|
||||||
{ model: 'gpt-3.5-turbo-0613', expected: 129 },
|
|
||||||
{ model: 'gpt-3.5-turbo', expected: 129 },
|
|
||||||
{ model: 'gpt-4-0314', expected: 129 },
|
|
||||||
{ model: 'gpt-4-0613', expected: 129 },
|
|
||||||
{ model: 'gpt-4', expected: 129 },
|
|
||||||
{ model: 'unknown', expected: 129 },
|
|
||||||
];
|
|
||||||
|
|
||||||
testCases.forEach((testCase) => {
|
|
||||||
it(`should return ${testCase.expected} tokens for model ${testCase.model}`, () => {
|
|
||||||
client.modelOptions.model = testCase.model;
|
|
||||||
// 3 tokens for assistant label
|
|
||||||
let totalTokens = 3;
|
|
||||||
for (let message of example_messages) {
|
|
||||||
totalTokens += client.getTokenCountForMessage(message);
|
|
||||||
}
|
|
||||||
expect(totalTokens).toBe(testCase.expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const vision_request = [
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: 'describe what is in this image?',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'image_url',
|
|
||||||
image_url: {
|
|
||||||
url: 'https://venturebeat.com/wp-content/uploads/2019/03/openai-1.png',
|
|
||||||
detail: 'high',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const expectedTokens = 14;
|
|
||||||
const visionModel = 'gpt-4-vision-preview';
|
|
||||||
|
|
||||||
it(`should return ${expectedTokens} tokens for model ${visionModel} (Vision Request)`, () => {
|
|
||||||
client.modelOptions.model = visionModel;
|
|
||||||
// 3 tokens for assistant label
|
|
||||||
let totalTokens = 3;
|
|
||||||
for (let message of vision_request) {
|
|
||||||
totalTokens += client.getTokenCountForMessage(message);
|
|
||||||
}
|
|
||||||
expect(totalTokens).toBe(expectedTokens);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('checkVisionRequest functionality', () => {
|
|
||||||
let client;
|
|
||||||
const attachments = [{ type: 'image/png' }];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
client = new OpenAIClient('test-api-key', {
|
|
||||||
endpoint: 'ollama',
|
|
||||||
modelOptions: {
|
|
||||||
model: 'initial-model',
|
|
||||||
},
|
|
||||||
modelsConfig: {
|
|
||||||
ollama: ['initial-model', 'llava', 'other-model'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
client.defaultVisionModel = 'non-valid-default-model';
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set "llava" as the model if it is the first valid model when default validation fails', () => {
|
|
||||||
client.checkVisionRequest(attachments);
|
|
||||||
|
|
||||||
expect(client.modelOptions.model).toBe('llava');
|
|
||||||
expect(client.isVisionModel).toBeTruthy();
|
|
||||||
expect(client.modelOptions.stop).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getStreamUsage', () => {
|
|
||||||
it('should return this.usage when completion_tokens_details is null', () => {
|
|
||||||
const client = new OpenAIClient('test-api-key', defaultOptions);
|
|
||||||
client.usage = {
|
|
||||||
completion_tokens_details: null,
|
|
||||||
prompt_tokens: 10,
|
|
||||||
completion_tokens: 20,
|
|
||||||
};
|
|
||||||
client.inputTokensKey = 'prompt_tokens';
|
|
||||||
client.outputTokensKey = 'completion_tokens';
|
|
||||||
|
|
||||||
const result = client.getStreamUsage();
|
|
||||||
|
|
||||||
expect(result).toEqual(client.usage);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return this.usage when completion_tokens_details is missing reasoning_tokens', () => {
|
|
||||||
const client = new OpenAIClient('test-api-key', defaultOptions);
|
|
||||||
client.usage = {
|
|
||||||
completion_tokens_details: {
|
|
||||||
other_tokens: 5,
|
|
||||||
},
|
|
||||||
prompt_tokens: 10,
|
|
||||||
completion_tokens: 20,
|
|
||||||
};
|
|
||||||
client.inputTokensKey = 'prompt_tokens';
|
|
||||||
client.outputTokensKey = 'completion_tokens';
|
|
||||||
|
|
||||||
const result = client.getStreamUsage();
|
|
||||||
|
|
||||||
expect(result).toEqual(client.usage);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate output tokens correctly when completion_tokens_details is present with reasoning_tokens', () => {
|
|
||||||
const client = new OpenAIClient('test-api-key', defaultOptions);
|
|
||||||
client.usage = {
|
|
||||||
completion_tokens_details: {
|
|
||||||
reasoning_tokens: 30,
|
|
||||||
other_tokens: 5,
|
|
||||||
},
|
|
||||||
prompt_tokens: 10,
|
|
||||||
completion_tokens: 20,
|
|
||||||
};
|
|
||||||
client.inputTokensKey = 'prompt_tokens';
|
|
||||||
client.outputTokensKey = 'completion_tokens';
|
|
||||||
|
|
||||||
const result = client.getStreamUsage();
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
reasoning_tokens: 30,
|
|
||||||
other_tokens: 5,
|
|
||||||
prompt_tokens: 10,
|
|
||||||
completion_tokens: 10, // |30 - 20| = 10
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return this.usage when it is undefined', () => {
|
|
||||||
const client = new OpenAIClient('test-api-key', defaultOptions);
|
|
||||||
client.usage = undefined;
|
|
||||||
|
|
||||||
const result = client.getStreamUsage();
|
|
||||||
|
|
||||||
expect(result).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
/*
|
|
||||||
This is a test script to see how much memory is used by the client when encoding.
|
|
||||||
On my work machine, it was able to process 10,000 encoding requests / 48.686 seconds = approximately 205.4 RPS
|
|
||||||
I've significantly reduced the amount of encoding needed by saving token counts in the database, so these
|
|
||||||
numbers should only be hit with a large amount of concurrent users
|
|
||||||
It would take 103 concurrent users sending 1 message every 1 second to hit these numbers, which is rather unrealistic,
|
|
||||||
and at that point, out-sourcing the encoding to a separate server would be a better solution
|
|
||||||
Also, for scaling, could increase the rate at which the encoder resets; the trade-off is more resource usage on the server.
|
|
||||||
Initial memory usage: 25.93 megabytes
|
|
||||||
Peak memory usage: 55 megabytes
|
|
||||||
Final memory usage: 28.03 megabytes
|
|
||||||
Post-test (timeout of 15s): 21.91 megabytes
|
|
||||||
*/
|
|
||||||
|
|
||||||
require('dotenv').config();
|
|
||||||
const { OpenAIClient } = require('../');
|
|
||||||
|
|
||||||
function timeout(ms) {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
const run = async () => {
|
|
||||||
const text = `
|
|
||||||
The standard Lorem Ipsum passage, used since the 1500s
|
|
||||||
|
|
||||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
|
|
||||||
Section 1.10.32 of "de Finibus Bonorum et Malorum", written by Cicero in 45 BC
|
|
||||||
|
|
||||||
"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?"
|
|
||||||
1914 translation by H. Rackham
|
|
||||||
|
|
||||||
"But I must explain to you how all this mistaken idea of denouncing pleasure and praising pain was born and I will give you a complete account of the system, and expound the actual teachings of the great explorer of the truth, the master-builder of human happiness. No one rejects, dislikes, or avoids pleasure itself, because it is pleasure, but because those who do not know how to pursue pleasure rationally encounter consequences that are extremely painful. Nor again is there anyone who loves or pursues or desires to obtain pain of itself, because it is pain, but because occasionally circumstances occur in which toil and pain can procure him some great pleasure. To take a trivial example, which of us ever undertakes laborious physical exercise, except to obtain some advantage from it? But who has any right to find fault with a man who chooses to enjoy a pleasure that has no annoying consequences, or one who avoids a pain that produces no resultant pleasure?"
|
|
||||||
Section 1.10.33 of "de Finibus Bonorum et Malorum", written by Cicero in 45 BC
|
|
||||||
|
|
||||||
"At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat."
|
|
||||||
1914 translation by H. Rackham
|
|
||||||
|
|
||||||
"On the other hand, we denounce with righteous indignation and dislike men who are so beguiled and demoralized by the charms of pleasure of the moment, so blinded by desire, that they cannot foresee the pain and trouble that are bound to ensue; and equal blame belongs to those who fail in their duty through weakness of will, which is the same as saying through shrinking from toil and pain. These cases are perfectly simple and easy to distinguish. In a free hour, when our power of choice is untrammelled and when nothing prevents our being able to do what we like best, every pleasure is to be welcomed and every pain avoided. But in certain circumstances and owing to the claims of duty or the obligations of business it will frequently occur that pleasures have to be repudiated and annoyances accepted. The wise man therefore always holds in these matters to this principle of selection: he rejects pleasures to secure other greater pleasures, or else he endures pains to avoid worse pains."
|
|
||||||
`;
|
|
||||||
const model = 'gpt-3.5-turbo';
|
|
||||||
let maxContextTokens = 4095;
|
|
||||||
if (model === 'gpt-4') {
|
|
||||||
maxContextTokens = 8191;
|
|
||||||
} else if (model === 'gpt-4-32k') {
|
|
||||||
maxContextTokens = 32767;
|
|
||||||
}
|
|
||||||
const clientOptions = {
|
|
||||||
reverseProxyUrl: process.env.OPENAI_REVERSE_PROXY || null,
|
|
||||||
maxContextTokens,
|
|
||||||
modelOptions: {
|
|
||||||
model,
|
|
||||||
},
|
|
||||||
proxy: process.env.PROXY || null,
|
|
||||||
debug: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
let apiKey = process.env.OPENAI_API_KEY;
|
|
||||||
|
|
||||||
const maxMemory = 0.05 * 1024 * 1024 * 1024;
|
|
||||||
|
|
||||||
// Calculate initial percentage of memory used
|
|
||||||
const initialMemoryUsage = process.memoryUsage().heapUsed;
|
|
||||||
|
|
||||||
function printProgressBar(percentageUsed) {
|
|
||||||
const filledBlocks = Math.round(percentageUsed / 2); // Each block represents 2%
|
|
||||||
const emptyBlocks = 50 - filledBlocks; // Total blocks is 50 (each represents 2%), so the rest are empty
|
|
||||||
const progressBar =
|
|
||||||
'[' +
|
|
||||||
'█'.repeat(filledBlocks) +
|
|
||||||
' '.repeat(emptyBlocks) +
|
|
||||||
'] ' +
|
|
||||||
percentageUsed.toFixed(2) +
|
|
||||||
'%';
|
|
||||||
console.log(progressBar);
|
|
||||||
}
|
|
||||||
|
|
||||||
const iterations = 10000;
|
|
||||||
console.time('loopTime');
|
|
||||||
// Trying to catch the error doesn't help; all future calls will immediately crash
|
|
||||||
for (let i = 0; i < iterations; i++) {
|
|
||||||
try {
|
|
||||||
console.log(`Iteration ${i}`);
|
|
||||||
const client = new OpenAIClient(apiKey, clientOptions);
|
|
||||||
|
|
||||||
client.getTokenCount(text);
|
|
||||||
// const encoder = client.constructor.getTokenizer('cl100k_base');
|
|
||||||
// console.log(`Iteration ${i}: call encode()...`);
|
|
||||||
// encoder.encode(text, 'all');
|
|
||||||
// encoder.free();
|
|
||||||
|
|
||||||
const memoryUsageDuringLoop = process.memoryUsage().heapUsed;
|
|
||||||
const percentageUsed = (memoryUsageDuringLoop / maxMemory) * 100;
|
|
||||||
printProgressBar(percentageUsed);
|
|
||||||
|
|
||||||
if (i === iterations - 1) {
|
|
||||||
console.log(' done');
|
|
||||||
// encoder.free();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`caught error! in Iteration ${i}`);
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.timeEnd('loopTime');
|
|
||||||
// Calculate final percentage of memory used
|
|
||||||
const finalMemoryUsage = process.memoryUsage().heapUsed;
|
|
||||||
// const finalPercentageUsed = finalMemoryUsage / maxMemory * 100;
|
|
||||||
console.log(`Initial memory usage: ${initialMemoryUsage / 1024 / 1024} megabytes`);
|
|
||||||
console.log(`Final memory usage: ${finalMemoryUsage / 1024 / 1024} megabytes`);
|
|
||||||
await timeout(15000);
|
|
||||||
const memoryUsageAfterTimeout = process.memoryUsage().heapUsed;
|
|
||||||
console.log(`Post timeout: ${memoryUsageAfterTimeout / 1024 / 1024} megabytes`);
|
|
||||||
};
|
|
||||||
|
|
||||||
run();
|
|
||||||
|
|
||||||
process.on('uncaughtException', (err) => {
|
|
||||||
if (!err.message.includes('fetch failed')) {
|
|
||||||
console.error('There was an uncaught error:');
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (err.message.includes('fetch failed')) {
|
|
||||||
console.log('fetch failed error caught');
|
|
||||||
// process.exit(0);
|
|
||||||
} else {
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": "v1",
|
|
||||||
"name_for_human": "Ai PDF",
|
|
||||||
"name_for_model": "Ai_PDF",
|
|
||||||
"description_for_human": "Super-fast, interactive chats with PDFs of any size, complete with page references for fact checking.",
|
|
||||||
"description_for_model": "Provide a URL to a PDF and search the document. Break the user question in multiple semantic search queries and calls as needed. Think step by step.",
|
|
||||||
"auth": {
|
|
||||||
"type": "none"
|
|
||||||
},
|
|
||||||
"api": {
|
|
||||||
"type": "openapi",
|
|
||||||
"url": "https://plugin-3c56b9d4c8a6465998395f28b6a445b2-jexkai4vea-uc.a.run.app/openapi.yaml",
|
|
||||||
"is_user_authenticated": false
|
|
||||||
},
|
|
||||||
"logo_url": "https://plugin-3c56b9d4c8a6465998395f28b6a445b2-jexkai4vea-uc.a.run.app/logo.png",
|
|
||||||
"contact_email": "support@promptapps.ai",
|
|
||||||
"legal_info_url": "https://plugin-3c56b9d4c8a6465998395f28b6a445b2-jexkai4vea-uc.a.run.app/legal.html"
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": "v1",
|
|
||||||
"name_for_human": "BrowserOp",
|
|
||||||
"name_for_model": "BrowserOp",
|
|
||||||
"description_for_human": "Browse dozens of webpages in one query. Fetch information more efficiently.",
|
|
||||||
"description_for_model": "This tool offers the feature for users to input a URL or multiple URLs and interact with them as needed. It's designed to comprehend the user's intent and proffer tailored suggestions in line with the content and functionality of the webpage at hand. Services like text rewrites, translations and more can be requested. When users need specific information to finish a task or if they intend to perform a search, this tool becomes a bridge to the search engine and generates responses based on the results. Whether the user is seeking information about restaurants, rentals, weather, or shopping, this tool connects to the internet and delivers the most recent results.",
|
|
||||||
"auth": {
|
|
||||||
"type": "none"
|
|
||||||
},
|
|
||||||
"api": {
|
|
||||||
"type": "openapi",
|
|
||||||
"url": "https://testplugin.feednews.com/.well-known/openapi.yaml"
|
|
||||||
},
|
|
||||||
"logo_url": "https://openapi-af.op-mobile.opera.com/openapi/testplugin/.well-known/logo.png",
|
|
||||||
"contact_email": "aiplugins-contact-list@opera.com",
|
|
||||||
"legal_info_url": "https://legal.apexnews.com/terms/"
|
|
||||||
}
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": "v1",
|
|
||||||
"name_for_human": "Dr. Thoth's Tarot",
|
|
||||||
"name_for_model": "Dr_Thoths_Tarot",
|
|
||||||
"description_for_human": "Tarot card novelty entertainment & analysis, by Mnemosyne Labs.",
|
|
||||||
"description_for_model": "Intelligent analysis program for tarot card entertaiment, data, & prompts, by Mnemosyne Labs, a division of AzothCorp.",
|
|
||||||
"auth": {
|
|
||||||
"type": "none"
|
|
||||||
},
|
|
||||||
"api": {
|
|
||||||
"type": "openapi",
|
|
||||||
"url": "https://dr-thoth-tarot.herokuapp.com/openapi.yaml",
|
|
||||||
"is_user_authenticated": false
|
|
||||||
},
|
|
||||||
"logo_url": "https://dr-thoth-tarot.herokuapp.com/logo.png",
|
|
||||||
"contact_email": "legal@AzothCorp.com",
|
|
||||||
"legal_info_url": "http://AzothCorp.com/legal",
|
|
||||||
"endpoints": [
|
|
||||||
{
|
|
||||||
"name": "Draw Card",
|
|
||||||
"path": "/drawcard",
|
|
||||||
"method": "GET",
|
|
||||||
"description": "Generate a single tarot card from the deck of 78 cards."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Occult Card",
|
|
||||||
"path": "/occult_card",
|
|
||||||
"method": "GET",
|
|
||||||
"description": "Generate a tarot card using the specified planet's Kamea matrix.",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "planet",
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury", "Moon"],
|
|
||||||
"required": true,
|
|
||||||
"description": "The planet name to use the corresponding Kamea matrix."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Three Card Spread",
|
|
||||||
"path": "/threecardspread",
|
|
||||||
"method": "GET",
|
|
||||||
"description": "Perform a three-card tarot spread."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Celtic Cross Spread",
|
|
||||||
"path": "/celticcross",
|
|
||||||
"method": "GET",
|
|
||||||
"description": "Perform a Celtic Cross tarot spread with 10 cards."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Past, Present, Future Spread",
|
|
||||||
"path": "/pastpresentfuture",
|
|
||||||
"method": "GET",
|
|
||||||
"description": "Perform a Past, Present, Future tarot spread with 3 cards."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Horseshoe Spread",
|
|
||||||
"path": "/horseshoe",
|
|
||||||
"method": "GET",
|
|
||||||
"description": "Perform a Horseshoe tarot spread with 7 cards."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Relationship Spread",
|
|
||||||
"path": "/relationship",
|
|
||||||
"method": "GET",
|
|
||||||
"description": "Perform a Relationship tarot spread."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Career Spread",
|
|
||||||
"path": "/career",
|
|
||||||
"method": "GET",
|
|
||||||
"description": "Perform a Career tarot spread."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Yes/No Spread",
|
|
||||||
"path": "/yesno",
|
|
||||||
"method": "GET",
|
|
||||||
"description": "Perform a Yes/No tarot spread."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Chakra Spread",
|
|
||||||
"path": "/chakra",
|
|
||||||
"method": "GET",
|
|
||||||
"description": "Perform a Chakra tarot spread with 7 cards."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": "v1",
|
|
||||||
"name_for_model": "DreamInterpreter",
|
|
||||||
"name_for_human": "Dream Interpreter",
|
|
||||||
"description_for_model": "Interprets your dreams using advanced techniques.",
|
|
||||||
"description_for_human": "Interprets your dreams using advanced techniques.",
|
|
||||||
"auth": {
|
|
||||||
"type": "none"
|
|
||||||
},
|
|
||||||
"api": {
|
|
||||||
"type": "openapi",
|
|
||||||
"url": "https://dreamplugin.bgnetmobile.com/.well-known/openapi.json",
|
|
||||||
"has_user_authentication": false
|
|
||||||
},
|
|
||||||
"logo_url": "https://dreamplugin.bgnetmobile.com/.well-known/logo.png",
|
|
||||||
"contact_email": "ismail.orkler@bgnetmobile.com",
|
|
||||||
"legal_info_url": "https://dreamplugin.bgnetmobile.com/terms.html"
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": "v1",
|
|
||||||
"name_for_human": "VoxScript",
|
|
||||||
"name_for_model": "VoxScript",
|
|
||||||
"description_for_human": "Enables searching of YouTube transcripts, financial data sources Google Search results, and more!",
|
|
||||||
"description_for_model": "Plugin for searching through varius data sources.",
|
|
||||||
"auth": {
|
|
||||||
"type": "service_http",
|
|
||||||
"authorization_type": "bearer",
|
|
||||||
"verification_tokens": {
|
|
||||||
"openai": "ffc5226d1af346c08a98dee7deec9f76"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"api": {
|
|
||||||
"type": "openapi",
|
|
||||||
"url": "https://voxscript.awt.icu/swagger/v1/swagger.yaml",
|
|
||||||
"is_user_authenticated": false
|
|
||||||
},
|
|
||||||
"logo_url": "https://voxscript.awt.icu/images/VoxScript_logo_32x32.png",
|
|
||||||
"contact_email": "voxscript@allwiretech.com",
|
|
||||||
"legal_info_url": "https://voxscript.awt.icu/legal/"
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": "v1",
|
|
||||||
"name_for_model": "askyourpdf",
|
|
||||||
"name_for_human": "AskYourPDF",
|
|
||||||
"description_for_model": "This plugin is designed to expedite the extraction of information from PDF documents. It works by accepting a URL link to a PDF or a document ID (doc_id) from the user. If a URL is provided, the plugin first validates that it is a correct URL. \\nAfter validating the URL, the plugin proceeds to download the PDF and store its content in a vector database. If the user provides a doc_id, the plugin directly retrieves the document from the database. The plugin then scans through the stored PDFs to find answers to user queries or retrieve specific details.\\n\\nHowever, if an error occurs while querying the API, the user is prompted to download their document first, then manually upload it to [](https://askyourpdf.com/upload). Once the upload is complete, the user should copy the resulting doc_id and paste it back into the chat for further interaction.\nThe plugin is particularly useful when the user's question pertains to content within a PDF document. When providing answers, the plugin also specifies the page number (highlighted in bold) where the relevant information was found. Remember, the URL must be valid for a successful query. Failure to validate the URL may lead to errors or unsuccessful queries.",
|
|
||||||
"description_for_human": "Unlock the power of your PDFs!, dive into your documents, find answers, and bring information to your fingertips.",
|
|
||||||
"auth": {
|
|
||||||
"type": "none"
|
|
||||||
},
|
|
||||||
"api": {
|
|
||||||
"type": "openapi",
|
|
||||||
"url": "askyourpdf.yaml",
|
|
||||||
"has_user_authentication": false
|
|
||||||
},
|
|
||||||
"logo_url": "https://plugin.askyourpdf.com/.well-known/logo.png",
|
|
||||||
"contact_email": "plugin@askyourpdf.com",
|
|
||||||
"legal_info_url": "https://askyourpdf.com/terms"
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": "v1",
|
|
||||||
"name_for_human": "Drink Maestro",
|
|
||||||
"name_for_model": "drink_maestro",
|
|
||||||
"description_for_human": "Learn to mix any drink you can imagine (real or made-up), and discover new ones. Includes drink images.",
|
|
||||||
"description_for_model": "You are a silly bartender/comic who knows how to make any drink imaginable. You provide recipes for specific drinks, suggest new drinks, and show pictures of drinks. Be creative in your descriptions and make jokes and puns. Use a lot of emojis. If the user makes a request in another language, send API call in English, and then translate the response.",
|
|
||||||
"auth": {
|
|
||||||
"type": "none"
|
|
||||||
},
|
|
||||||
"api": {
|
|
||||||
"type": "openapi",
|
|
||||||
"url": "https://api.drinkmaestro.space/.well-known/openapi.yaml",
|
|
||||||
"is_user_authenticated": false
|
|
||||||
},
|
|
||||||
"logo_url": "https://i.imgur.com/6q8HWdz.png",
|
|
||||||
"contact_email": "nikkmitchell@gmail.com",
|
|
||||||
"legal_info_url": "https://github.com/nikkmitchell/DrinkMaestro/blob/main/Legal.txt"
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": "v1",
|
|
||||||
"name_for_human": "Earth",
|
|
||||||
"name_for_model": "earthImagesAndVisualizations",
|
|
||||||
"description_for_human": "Generates a map image based on provided location, tilt and style.",
|
|
||||||
"description_for_model": "Generates a map image based on provided coordinates or location, tilt and style, and even geoJson to provide markers, paths, and polygons. Responds with an image-link. For the styles choose one of these: [light, dark, streets, outdoors, satellite, satellite-streets]",
|
|
||||||
"auth": {
|
|
||||||
"type": "none"
|
|
||||||
},
|
|
||||||
"api": {
|
|
||||||
"type": "openapi",
|
|
||||||
"url": "https://api.earth-plugin.com/openapi.yaml",
|
|
||||||
"is_user_authenticated": false
|
|
||||||
},
|
|
||||||
"logo_url": "https://api.earth-plugin.com/logo.png",
|
|
||||||
"contact_email": "contact@earth-plugin.com",
|
|
||||||
"legal_info_url": "https://api.earth-plugin.com/legal.html"
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": "v1",
|
|
||||||
"name_for_human": "Scholarly Graph Link",
|
|
||||||
"name_for_model": "scholarly_graph_link",
|
|
||||||
"description_for_human": "You can search papers, authors, datasets and software. It has access to Figshare, Arxiv, and many others.",
|
|
||||||
"description_for_model": "Run GraphQL queries against an API hosted by DataCite API. The API supports most GraphQL query but does not support mutations statements. Use `{ __schema { types { name kind } } }` to get all the types in the GraphQL schema. Use `{ datasets { nodes { id sizes citations { nodes { id titles { title } } } } } }` to get all the citations of all datasets in the API. Use `{ datasets { nodes { id sizes citations { nodes { id titles { title } } } } } }` to get all the citations of all datasets in the API. Use `{person(id:ORCID) {works(first:50) {nodes {id titles(first: 1){title} publicationYear}}}}` to get the first 50 works of a person based on their ORCID. All Ids are urls, e.g., https://orcid.org/0012-0000-1012-1110. Mutations statements are not allowed.",
|
|
||||||
"auth": {
|
|
||||||
"type": "none"
|
|
||||||
},
|
|
||||||
"api": {
|
|
||||||
"type": "openapi",
|
|
||||||
"url": "https://api.datacite.org/graphql-openapi.yaml",
|
|
||||||
"is_user_authenticated": false
|
|
||||||
},
|
|
||||||
"logo_url": "https://raw.githubusercontent.com/kjgarza/scholarly_graph_link/master/logo.png",
|
|
||||||
"contact_email": "kj.garza@gmail.com",
|
|
||||||
"legal_info_url": "https://github.com/kjgarza/scholarly_graph_link/blob/master/LICENSE"
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": "v1",
|
|
||||||
"name_for_human": "WebPilot",
|
|
||||||
"name_for_model": "web_pilot",
|
|
||||||
"description_for_human": "Browse & QA Webpage/PDF/Data. Generate articles, from one or more URLs.",
|
|
||||||
"description_for_model": "This tool allows users to provide a URL(or URLs) and optionally requests for interacting with, extracting specific information or how to do with the content from the URL. Requests may include rewrite, translate, and others. If there any requests, when accessing the /api/visit-web endpoint, the parameter 'user_has_request' should be set to 'true. And if there's no any requests, 'user_has_request' should be set to 'false'.",
|
|
||||||
"auth": {
|
|
||||||
"type": "none"
|
|
||||||
},
|
|
||||||
"api": {
|
|
||||||
"type": "openapi",
|
|
||||||
"url": "https://webreader.webpilotai.com/openapi.yaml",
|
|
||||||
"is_user_authenticated": false
|
|
||||||
},
|
|
||||||
"logo_url": "https://webreader.webpilotai.com/logo.png",
|
|
||||||
"contact_email": "dev@webpilot.ai",
|
|
||||||
"legal_info_url": "https://webreader.webpilotai.com/legal_info.html",
|
|
||||||
"headers": {
|
|
||||||
"id": "WebPilot-Friend-UID"
|
|
||||||
},
|
|
||||||
"params": {
|
|
||||||
"user_has_request": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": "v1",
|
|
||||||
"name_for_human": "Image Prompt Enhancer",
|
|
||||||
"name_for_model": "image_prompt_enhancer",
|
|
||||||
"description_for_human": "Transform your ideas into complex, personalized image generation prompts.",
|
|
||||||
"description_for_model": "Provides instructions for crafting an enhanced image prompt. Use this whenever the user wants to enhance a prompt.",
|
|
||||||
"auth": {
|
|
||||||
"type": "none"
|
|
||||||
},
|
|
||||||
"api": {
|
|
||||||
"type": "openapi",
|
|
||||||
"url": "https://image-prompt-enhancer.gafo.tech/openapi.yaml",
|
|
||||||
"is_user_authenticated": false
|
|
||||||
},
|
|
||||||
"logo_url": "https://image-prompt-enhancer.gafo.tech/logo.png",
|
|
||||||
"contact_email": "gafotech1@gmail.com",
|
|
||||||
"legal_info_url": "https://image-prompt-enhancer.gafo.tech/legal"
|
|
||||||
}
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
openapi: 3.0.2
|
|
||||||
info:
|
|
||||||
title: FastAPI
|
|
||||||
version: 0.1.0
|
|
||||||
servers:
|
|
||||||
- url: https://plugin.askyourpdf.com
|
|
||||||
paths:
|
|
||||||
/api/download_pdf:
|
|
||||||
post:
|
|
||||||
summary: Download Pdf
|
|
||||||
description: Download a PDF file from a URL and save it to the vector database.
|
|
||||||
operationId: download_pdf_api_download_pdf_post
|
|
||||||
parameters:
|
|
||||||
- required: true
|
|
||||||
schema:
|
|
||||||
title: Url
|
|
||||||
type: string
|
|
||||||
name: url
|
|
||||||
in: query
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Successful Response
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/FileResponse'
|
|
||||||
'422':
|
|
||||||
description: Validation Error
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/HTTPValidationError'
|
|
||||||
/query:
|
|
||||||
post:
|
|
||||||
summary: Perform Query
|
|
||||||
description: Perform a query on a document.
|
|
||||||
operationId: perform_query_query_post
|
|
||||||
requestBody:
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/InputData'
|
|
||||||
required: true
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Successful Response
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ResponseModel'
|
|
||||||
'422':
|
|
||||||
description: Validation Error
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/HTTPValidationError'
|
|
||||||
components:
|
|
||||||
schemas:
|
|
||||||
DocumentMetadata:
|
|
||||||
title: DocumentMetadata
|
|
||||||
required:
|
|
||||||
- source
|
|
||||||
- page_number
|
|
||||||
- author
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
source:
|
|
||||||
title: Source
|
|
||||||
type: string
|
|
||||||
page_number:
|
|
||||||
title: Page Number
|
|
||||||
type: integer
|
|
||||||
author:
|
|
||||||
title: Author
|
|
||||||
type: string
|
|
||||||
FileResponse:
|
|
||||||
title: FileResponse
|
|
||||||
required:
|
|
||||||
- docId
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
docId:
|
|
||||||
title: Docid
|
|
||||||
type: string
|
|
||||||
error:
|
|
||||||
title: Error
|
|
||||||
type: string
|
|
||||||
HTTPValidationError:
|
|
||||||
title: HTTPValidationError
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
detail:
|
|
||||||
title: Detail
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/ValidationError'
|
|
||||||
InputData:
|
|
||||||
title: InputData
|
|
||||||
required:
|
|
||||||
- doc_id
|
|
||||||
- query
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
doc_id:
|
|
||||||
title: Doc Id
|
|
||||||
type: string
|
|
||||||
query:
|
|
||||||
title: Query
|
|
||||||
type: string
|
|
||||||
ResponseModel:
|
|
||||||
title: ResponseModel
|
|
||||||
required:
|
|
||||||
- results
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
results:
|
|
||||||
title: Results
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/SearchResult'
|
|
||||||
SearchResult:
|
|
||||||
title: SearchResult
|
|
||||||
required:
|
|
||||||
- doc_id
|
|
||||||
- text
|
|
||||||
- metadata
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
doc_id:
|
|
||||||
title: Doc Id
|
|
||||||
type: string
|
|
||||||
text:
|
|
||||||
title: Text
|
|
||||||
type: string
|
|
||||||
metadata:
|
|
||||||
$ref: '#/components/schemas/DocumentMetadata'
|
|
||||||
ValidationError:
|
|
||||||
title: ValidationError
|
|
||||||
required:
|
|
||||||
- loc
|
|
||||||
- msg
|
|
||||||
- type
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
loc:
|
|
||||||
title: Location
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
anyOf:
|
|
||||||
- type: string
|
|
||||||
- type: integer
|
|
||||||
msg:
|
|
||||||
title: Message
|
|
||||||
type: string
|
|
||||||
type:
|
|
||||||
title: Error Type
|
|
||||||
type: string
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
openapi: 3.0.1
|
|
||||||
info:
|
|
||||||
title: ScholarAI
|
|
||||||
description: Allows the user to search facts and findings from scientific articles
|
|
||||||
version: 'v1'
|
|
||||||
servers:
|
|
||||||
- url: https://scholar-ai.net
|
|
||||||
paths:
|
|
||||||
/api/abstracts:
|
|
||||||
get:
|
|
||||||
operationId: searchAbstracts
|
|
||||||
summary: Get relevant paper abstracts by keywords search
|
|
||||||
parameters:
|
|
||||||
- name: keywords
|
|
||||||
in: query
|
|
||||||
description: Keywords of inquiry which should appear in article. Must be in English.
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
- name: sort
|
|
||||||
in: query
|
|
||||||
description: The sort order for results. Valid values are cited_by_count or publication_date. Excluding this value does a relevance based search.
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- cited_by_count
|
|
||||||
- publication_date
|
|
||||||
- name: query
|
|
||||||
in: query
|
|
||||||
description: The user query
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
- name: peer_reviewed_only
|
|
||||||
in: query
|
|
||||||
description: Whether to only return peer reviewed articles. Defaults to true, ChatGPT should cautiously suggest this value can be set to false
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
- name: start_year
|
|
||||||
in: query
|
|
||||||
description: The first year, inclusive, to include in the search range. Excluding this value will include all years.
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
- name: end_year
|
|
||||||
in: query
|
|
||||||
description: The last year, inclusive, to include in the search range. Excluding this value will include all years.
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
- name: offset
|
|
||||||
in: query
|
|
||||||
description: The offset of the first result to return. Defaults to 0.
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: OK
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/searchAbstractsResponse'
|
|
||||||
/api/fulltext:
|
|
||||||
get:
|
|
||||||
operationId: getFullText
|
|
||||||
summary: Get full text of a paper by URL for PDF
|
|
||||||
parameters:
|
|
||||||
- name: pdf_url
|
|
||||||
in: query
|
|
||||||
description: URL for PDF
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
- name: chunk
|
|
||||||
in: query
|
|
||||||
description: chunk number to retrieve, defaults to 1
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: number
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: OK
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/getFullTextResponse'
|
|
||||||
/api/save-citation:
|
|
||||||
get:
|
|
||||||
operationId: saveCitation
|
|
||||||
summary: Save citation to reference manager
|
|
||||||
parameters:
|
|
||||||
- name: doi
|
|
||||||
in: query
|
|
||||||
description: Digital Object Identifier (DOI) of article
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
- name: zotero_user_id
|
|
||||||
in: query
|
|
||||||
description: Zotero User ID
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
- name: zotero_api_key
|
|
||||||
in: query
|
|
||||||
description: Zotero API Key
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: OK
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/saveCitationResponse'
|
|
||||||
components:
|
|
||||||
schemas:
|
|
||||||
searchAbstractsResponse:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
next_offset:
|
|
||||||
type: number
|
|
||||||
description: The offset of the next page of results.
|
|
||||||
total_num_results:
|
|
||||||
type: number
|
|
||||||
description: The total number of results.
|
|
||||||
abstracts:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
title:
|
|
||||||
type: string
|
|
||||||
abstract:
|
|
||||||
type: string
|
|
||||||
description: Summary of the context, methods, results, and conclusions of the paper.
|
|
||||||
doi:
|
|
||||||
type: string
|
|
||||||
description: The DOI of the paper.
|
|
||||||
landing_page_url:
|
|
||||||
type: string
|
|
||||||
description: Link to the paper on its open-access host.
|
|
||||||
pdf_url:
|
|
||||||
type: string
|
|
||||||
description: Link to the paper PDF.
|
|
||||||
publicationDate:
|
|
||||||
type: string
|
|
||||||
description: The date the paper was published in YYYY-MM-DD format.
|
|
||||||
relevance:
|
|
||||||
type: number
|
|
||||||
description: The relevance of the paper to the search query. 1 is the most relevant.
|
|
||||||
creators:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
description: The name of the creator.
|
|
||||||
cited_by_count:
|
|
||||||
type: number
|
|
||||||
description: The number of citations of the article.
|
|
||||||
description: The list of relevant abstracts.
|
|
||||||
getFullTextResponse:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
full_text:
|
|
||||||
type: string
|
|
||||||
description: The full text of the paper.
|
|
||||||
pdf_url:
|
|
||||||
type: string
|
|
||||||
description: The PDF URL of the paper.
|
|
||||||
chunk:
|
|
||||||
type: number
|
|
||||||
description: The chunk of the paper.
|
|
||||||
total_chunk_num:
|
|
||||||
type: number
|
|
||||||
description: The total chunks of the paper.
|
|
||||||
saveCitationResponse:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
message:
|
|
||||||
type: string
|
|
||||||
description: Confirmation of successful save or error message.
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": "v1",
|
|
||||||
"name_for_human": "QR Codes",
|
|
||||||
"name_for_model": "qrCodes",
|
|
||||||
"description_for_human": "Create QR codes.",
|
|
||||||
"description_for_model": "Plugin for generating QR codes.",
|
|
||||||
"auth": {
|
|
||||||
"type": "none"
|
|
||||||
},
|
|
||||||
"api": {
|
|
||||||
"type": "openapi",
|
|
||||||
"url": "https://chatgpt-qrcode-46d7d4ebefc8.herokuapp.com/openapi.yaml"
|
|
||||||
},
|
|
||||||
"logo_url": "https://chatgpt-qrcode-46d7d4ebefc8.herokuapp.com/logo.png",
|
|
||||||
"contact_email": "chrismountzou@gmail.com",
|
|
||||||
"legal_info_url": "https://raw.githubusercontent.com/mountzou/qrCodeGPTv1/master/legal"
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": "v1",
|
|
||||||
"name_for_human": "ScholarAI",
|
|
||||||
"name_for_model": "scholarai",
|
|
||||||
"description_for_human": "Unleash scientific research: search 40M+ peer-reviewed papers, explore scientific PDFs, and save to reference managers.",
|
|
||||||
"description_for_model": "Access open access scientific literature from peer-reviewed journals. The abstract endpoint finds relevant papers based on 2 to 6 keywords. After getting abstracts, ALWAYS prompt the user offering to go into more detail. Use the fulltext endpoint to retrieve the entire paper's text and access specific details using the provided pdf_url, if available. ALWAYS hyperlink the pdf_url from the responses if available. Offer to dive into the fulltext or search for additional papers. Always ask if the user wants save any paper to the user’s Zotero reference manager by using the save-citation endpoint and providing the doi and requesting the user’s zotero_user_id and zotero_api_key.",
|
|
||||||
"auth": {
|
|
||||||
"type": "none"
|
|
||||||
},
|
|
||||||
"api": {
|
|
||||||
"type": "openapi",
|
|
||||||
"url": "scholarai.yaml",
|
|
||||||
"is_user_authenticated": false
|
|
||||||
},
|
|
||||||
"params": {
|
|
||||||
"sort": "cited_by_count"
|
|
||||||
},
|
|
||||||
"logo_url": "https://scholar-ai.net/logo.png",
|
|
||||||
"contact_email": "lakshb429@gmail.com",
|
|
||||||
"legal_info_url": "https://scholar-ai.net/legal.txt",
|
|
||||||
"HttpAuthorizationType": "basic"
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": "v1",
|
|
||||||
"name_for_human": "Uberchord",
|
|
||||||
"name_for_model": "uberchord",
|
|
||||||
"description_for_human": "Find guitar chord diagrams by specifying the chord name.",
|
|
||||||
"description_for_model": "Fetch guitar chord diagrams, their positions on the guitar fretboard.",
|
|
||||||
"auth": {
|
|
||||||
"type": "none"
|
|
||||||
},
|
|
||||||
"api": {
|
|
||||||
"type": "openapi",
|
|
||||||
"url": "https://guitarchords.pluginboost.com/.well-known/openapi.yaml",
|
|
||||||
"is_user_authenticated": false
|
|
||||||
},
|
|
||||||
"logo_url": "https://guitarchords.pluginboost.com/logo.png",
|
|
||||||
"contact_email": "info.bluelightweb@gmail.com",
|
|
||||||
"legal_info_url": "https://guitarchords.pluginboost.com/legal"
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": "v1",
|
|
||||||
"name_for_human": "Web Search",
|
|
||||||
"name_for_model": "web_search",
|
|
||||||
"description_for_human": "Search for information from the internet",
|
|
||||||
"description_for_model": "Search for information from the internet",
|
|
||||||
"auth": {
|
|
||||||
"type": "none"
|
|
||||||
},
|
|
||||||
"api": {
|
|
||||||
"type": "openapi",
|
|
||||||
"url": "https://websearch.plugsugar.com/api/openapi_yaml",
|
|
||||||
"is_user_authenticated": false
|
|
||||||
},
|
|
||||||
"logo_url": "https://websearch.plugsugar.com/200x200.png",
|
|
||||||
"contact_email": "support@plugsugar.com",
|
|
||||||
"legal_info_url": "https://websearch.plugsugar.com/contact"
|
|
||||||
}
|
|
||||||
|
|
@ -5,13 +5,13 @@ const DALLE3 = require('./structured/DALLE3');
|
||||||
const FluxAPI = require('./structured/FluxAPI');
|
const FluxAPI = require('./structured/FluxAPI');
|
||||||
const OpenWeather = require('./structured/OpenWeather');
|
const OpenWeather = require('./structured/OpenWeather');
|
||||||
const StructuredWolfram = require('./structured/Wolfram');
|
const StructuredWolfram = require('./structured/Wolfram');
|
||||||
const createYouTubeTools = require('./structured/YouTube');
|
|
||||||
const StructuredACS = require('./structured/AzureAISearch');
|
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 createOpenAIImageTools = require('./structured/OpenAIImageTools');
|
||||||
const TavilySearchResults = require('./structured/TavilySearchResults');
|
const TavilySearchResults = require('./structured/TavilySearchResults');
|
||||||
|
const createGeminiImageTool = require('./structured/GeminiImageGen');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
...manifest,
|
...manifest,
|
||||||
|
|
@ -24,7 +24,7 @@ module.exports = {
|
||||||
GoogleSearchAPI,
|
GoogleSearchAPI,
|
||||||
TraversaalSearch,
|
TraversaalSearch,
|
||||||
StructuredWolfram,
|
StructuredWolfram,
|
||||||
createYouTubeTools,
|
|
||||||
TavilySearchResults,
|
TavilySearchResults,
|
||||||
createOpenAIImageTools,
|
createOpenAIImageTools,
|
||||||
|
createGeminiImageTool,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -30,20 +30,6 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "YouTube",
|
|
||||||
"pluginKey": "youtube",
|
|
||||||
"toolkit": true,
|
|
||||||
"description": "Get YouTube video information, retrieve comments, analyze transcripts and search for videos.",
|
|
||||||
"icon": "https://www.youtube.com/s/desktop/7449ebf7/img/favicon_144x144.png",
|
|
||||||
"authConfig": [
|
|
||||||
{
|
|
||||||
"authField": "YOUTUBE_API_KEY",
|
|
||||||
"label": "YouTube API Key",
|
|
||||||
"description": "Your YouTube Data API v3 key."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "OpenAI Image Tools",
|
"name": "OpenAI Image Tools",
|
||||||
"pluginKey": "image_gen_oai",
|
"pluginKey": "image_gen_oai",
|
||||||
|
|
@ -179,5 +165,19 @@
|
||||||
"description": "Provide your Flux API key from your user profile."
|
"description": "Provide your Flux API key from your user profile."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Gemini Image Tools",
|
||||||
|
"pluginKey": "gemini_image_gen",
|
||||||
|
"toolkit": true,
|
||||||
|
"description": "Generate high-quality images using Google's Gemini Image Models. Supports Gemini API or Vertex AI.",
|
||||||
|
"icon": "assets/gemini_image_gen.svg",
|
||||||
|
"authConfig": [
|
||||||
|
{
|
||||||
|
"authField": "GEMINI_API_KEY||GOOGLE_KEY||GEMINI_VERTEX_ENABLED",
|
||||||
|
"label": "Gemini API Key (Optional if Vertex AI is configured)",
|
||||||
|
"description": "Your Google Gemini API Key from <a href='https://aistudio.google.com/app/apikey' target='_blank'>Google AI Studio</a>. Leave blank if using Vertex AI with service account."
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,8 @@ const { v4: uuidv4 } = require('uuid');
|
||||||
const { ProxyAgent, fetch } = require('undici');
|
const { ProxyAgent, fetch } = require('undici');
|
||||||
const { Tool } = require('@langchain/core/tools');
|
const { Tool } = require('@langchain/core/tools');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { getImageBasename } = require('@librechat/api');
|
const { getImageBasename, extractBaseURL } = require('@librechat/api');
|
||||||
const { FileContext, ContentTypes } = require('librechat-data-provider');
|
const { FileContext, ContentTypes } = require('librechat-data-provider');
|
||||||
const extractBaseURL = require('~/utils/extractBaseURL');
|
|
||||||
|
|
||||||
const displayMessage =
|
const displayMessage =
|
||||||
"DALL-E displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.";
|
"DALL-E displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.";
|
||||||
|
|
|
||||||
595
api/app/clients/tools/structured/GeminiImageGen.js
Normal file
595
api/app/clients/tools/structured/GeminiImageGen.js
Normal file
|
|
@ -0,0 +1,595 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const sharp = require('sharp');
|
||||||
|
const { v4 } = require('uuid');
|
||||||
|
const { ProxyAgent } = require('undici');
|
||||||
|
const { GoogleGenAI } = require('@google/genai');
|
||||||
|
const { tool } = require('@langchain/core/tools');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const {
|
||||||
|
FileContext,
|
||||||
|
ContentTypes,
|
||||||
|
FileSources,
|
||||||
|
EImageOutputType,
|
||||||
|
} = require('librechat-data-provider');
|
||||||
|
const {
|
||||||
|
geminiToolkit,
|
||||||
|
loadServiceKey,
|
||||||
|
getBalanceConfig,
|
||||||
|
getTransactionsConfig,
|
||||||
|
} = require('@librechat/api');
|
||||||
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||||
|
const { spendTokens } = require('~/models/spendTokens');
|
||||||
|
const { getFiles } = require('~/models/File');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure proxy support for Google APIs
|
||||||
|
* This wraps globalThis.fetch to add a proxy dispatcher only for googleapis.com URLs
|
||||||
|
* This is necessary because @google/genai SDK doesn't support custom fetch or httpOptions.dispatcher
|
||||||
|
*/
|
||||||
|
if (process.env.PROXY) {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const proxyAgent = new ProxyAgent(process.env.PROXY);
|
||||||
|
|
||||||
|
globalThis.fetch = function (url, options = {}) {
|
||||||
|
const urlString = url.toString();
|
||||||
|
if (urlString.includes('googleapis.com')) {
|
||||||
|
options = { ...options, dispatcher: proxyAgent };
|
||||||
|
}
|
||||||
|
return originalFetch.call(this, url, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default service key file path (consistent with main Google endpoint)
|
||||||
|
* @returns {string} - The default path to the service key file
|
||||||
|
*/
|
||||||
|
function getDefaultServiceKeyPath() {
|
||||||
|
return (
|
||||||
|
process.env.GOOGLE_SERVICE_KEY_FILE || path.join(process.cwd(), 'api', 'data', 'auth.json')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayMessage =
|
||||||
|
"Gemini 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(/[^\w\s\-_.,!?()]/g, '') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and sanitize image format
|
||||||
|
* @param {string} format - The format to validate
|
||||||
|
* @returns {string} - Safe format
|
||||||
|
*/
|
||||||
|
function getSafeFormat(format) {
|
||||||
|
const allowedFormats = ['png', 'jpg', 'jpeg', 'webp', 'gif'];
|
||||||
|
return allowedFormats.includes(format?.toLowerCase()) ? format.toLowerCase() : 'png';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert image buffer to target format if needed
|
||||||
|
* @param {Buffer} inputBuffer - The input image buffer
|
||||||
|
* @param {string} targetFormat - The target format (png, jpeg, webp)
|
||||||
|
* @returns {Promise<{buffer: Buffer, format: string}>} - Converted buffer and format
|
||||||
|
*/
|
||||||
|
async function convertImageFormat(inputBuffer, targetFormat) {
|
||||||
|
const metadata = await sharp(inputBuffer).metadata();
|
||||||
|
const currentFormat = metadata.format;
|
||||||
|
|
||||||
|
// Normalize format names (jpg -> jpeg)
|
||||||
|
const normalizedTarget = targetFormat === 'jpg' ? 'jpeg' : targetFormat.toLowerCase();
|
||||||
|
const normalizedCurrent = currentFormat === 'jpg' ? 'jpeg' : currentFormat;
|
||||||
|
|
||||||
|
// If already in target format, return as-is
|
||||||
|
if (normalizedCurrent === normalizedTarget) {
|
||||||
|
return { buffer: inputBuffer, format: normalizedTarget };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to target format
|
||||||
|
const convertedBuffer = await sharp(inputBuffer).toFormat(normalizedTarget).toBuffer();
|
||||||
|
return { buffer: convertedBuffer, format: normalizedTarget };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Gemini client (supports both Gemini API and Vertex AI)
|
||||||
|
* Priority: API key (from options, resolved by loadAuthValues) > Vertex AI service account
|
||||||
|
* @param {Object} options - Initialization options
|
||||||
|
* @param {string} [options.GEMINI_API_KEY] - Gemini API key (resolved by loadAuthValues)
|
||||||
|
* @param {string} [options.GOOGLE_KEY] - Google API key (resolved by loadAuthValues)
|
||||||
|
* @returns {Promise<GoogleGenAI>} - The initialized client
|
||||||
|
*/
|
||||||
|
async function initializeGeminiClient(options = {}) {
|
||||||
|
const geminiKey = options.GEMINI_API_KEY;
|
||||||
|
if (geminiKey) {
|
||||||
|
logger.debug('[GeminiImageGen] Using Gemini API with GEMINI_API_KEY');
|
||||||
|
return new GoogleGenAI({ apiKey: geminiKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
const googleKey = options.GOOGLE_KEY;
|
||||||
|
if (googleKey) {
|
||||||
|
logger.debug('[GeminiImageGen] Using Gemini API with GOOGLE_KEY');
|
||||||
|
return new GoogleGenAI({ apiKey: googleKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to Vertex AI with service account
|
||||||
|
logger.debug('[GeminiImageGen] Using Vertex AI with service account');
|
||||||
|
const credentialsPath = getDefaultServiceKeyPath();
|
||||||
|
|
||||||
|
// Use loadServiceKey for consistent loading (supports file paths, JSON strings, base64)
|
||||||
|
const serviceKey = await loadServiceKey(credentialsPath);
|
||||||
|
|
||||||
|
if (!serviceKey || !serviceKey.project_id) {
|
||||||
|
throw new Error(
|
||||||
|
'Gemini Image Generation requires one of: user-provided API key, GEMINI_API_KEY or GOOGLE_KEY env var, or a valid Google service account. ' +
|
||||||
|
`Service account file not found or invalid at: ${credentialsPath}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set GOOGLE_APPLICATION_CREDENTIALS for any Google Cloud SDK dependencies
|
||||||
|
try {
|
||||||
|
await fs.promises.access(credentialsPath);
|
||||||
|
process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist, skip setting env var
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GoogleGenAI({
|
||||||
|
vertexai: true,
|
||||||
|
project: serviceKey.project_id,
|
||||||
|
location: process.env.GOOGLE_LOC || process.env.GOOGLE_CLOUD_LOCATION || 'global',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save image to local filesystem
|
||||||
|
* @param {string} base64Data - Base64 encoded image data
|
||||||
|
* @param {string} format - Image format
|
||||||
|
* @param {string} userId - User ID
|
||||||
|
* @returns {Promise<string>} - The relative URL
|
||||||
|
*/
|
||||||
|
async function saveImageLocally(base64Data, format, userId) {
|
||||||
|
const safeFormat = getSafeFormat(format);
|
||||||
|
const safeUserId = userId ? path.basename(userId) : 'default';
|
||||||
|
const imageName = `gemini-img-${v4()}.${safeFormat}`;
|
||||||
|
const userDir = path.join(process.cwd(), 'client/public/images', safeUserId);
|
||||||
|
|
||||||
|
await fs.promises.mkdir(userDir, { recursive: true });
|
||||||
|
|
||||||
|
const filePath = path.join(userDir, imageName);
|
||||||
|
await fs.promises.writeFile(filePath, Buffer.from(base64Data, 'base64'));
|
||||||
|
|
||||||
|
logger.debug('[GeminiImageGen] Image saved locally to:', filePath);
|
||||||
|
return `/images/${safeUserId}/${imageName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save image to cloud storage
|
||||||
|
* @param {Object} params - Parameters
|
||||||
|
* @returns {Promise<string|null>} - The storage URL or null
|
||||||
|
*/
|
||||||
|
async function saveToCloudStorage({ base64Data, format, processFileURL, fileStrategy, userId }) {
|
||||||
|
if (!processFileURL || !fileStrategy || !userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const safeFormat = getSafeFormat(format);
|
||||||
|
const safeUserId = path.basename(userId);
|
||||||
|
const dataURL = `data:image/${safeFormat};base64,${base64Data}`;
|
||||||
|
const imageName = `gemini-img-${v4()}.${safeFormat}`;
|
||||||
|
|
||||||
|
const result = await processFileURL({
|
||||||
|
URL: dataURL,
|
||||||
|
basePath: 'images',
|
||||||
|
userId: safeUserId,
|
||||||
|
fileName: imageName,
|
||||||
|
fileStrategy,
|
||||||
|
context: FileContext.image_generation,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.filepath;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[GeminiImageGen] Error saving to cloud storage:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert image files to Gemini inline data format
|
||||||
|
* @param {Object} params - Parameters
|
||||||
|
* @returns {Promise<Array>} - Array of inline data objects
|
||||||
|
*/
|
||||||
|
async function convertImagesToInlineData({ imageFiles, image_ids, req, fileStrategy }) {
|
||||||
|
if (!image_ids || image_ids.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 && req?.user?.id) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inlineDataArray = [];
|
||||||
|
for (const imageFile of orderedFiles) {
|
||||||
|
if (!imageFile) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const source = imageFile.source || fileStrategy;
|
||||||
|
if (!source) continue;
|
||||||
|
|
||||||
|
let getDownloadStream = streamMethods[source];
|
||||||
|
if (!getDownloadStream) {
|
||||||
|
({ getDownloadStream } = getStrategyFunctions(source));
|
||||||
|
streamMethods[source] = getDownloadStream;
|
||||||
|
}
|
||||||
|
if (!getDownloadStream) continue;
|
||||||
|
|
||||||
|
const stream = await getDownloadStream(req, imageFile.filepath);
|
||||||
|
if (!stream) continue;
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
const base64Data = buffer.toString('base64');
|
||||||
|
const mimeType = imageFile.type || 'image/png';
|
||||||
|
|
||||||
|
inlineDataArray.push({
|
||||||
|
inlineData: { mimeType, data: base64Data },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[GeminiImageGen] Error processing image:', imageFile.file_id, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inlineDataArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for safety blocks in API response
|
||||||
|
* @param {Object} response - The API response
|
||||||
|
* @returns {Object|null} - Safety block info or null
|
||||||
|
*/
|
||||||
|
function checkForSafetyBlock(response) {
|
||||||
|
if (!response?.candidates?.length) {
|
||||||
|
return { reason: 'NO_CANDIDATES', message: 'No candidates returned' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = response.candidates[0];
|
||||||
|
const finishReason = candidate.finishReason;
|
||||||
|
|
||||||
|
if (finishReason === 'SAFETY' || finishReason === 'PROHIBITED_CONTENT') {
|
||||||
|
return { reason: finishReason, message: 'Content blocked by safety filters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finishReason === 'RECITATION') {
|
||||||
|
return { reason: finishReason, message: 'Content blocked due to recitation concerns' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.safetyRatings) {
|
||||||
|
for (const rating of candidate.safetyRatings) {
|
||||||
|
if (rating.probability === 'HIGH' || rating.blocked === true) {
|
||||||
|
return {
|
||||||
|
reason: 'SAFETY_RATING',
|
||||||
|
message: `Blocked due to ${rating.category}`,
|
||||||
|
category: rating.category,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record token usage for balance tracking
|
||||||
|
* @param {Object} params - Parameters
|
||||||
|
* @param {Object} params.usageMetadata - The usage metadata from API response
|
||||||
|
* @param {Object} params.req - The request object
|
||||||
|
* @param {string} params.userId - The user ID
|
||||||
|
* @param {string} params.conversationId - The conversation ID
|
||||||
|
* @param {string} params.model - The model name
|
||||||
|
*/
|
||||||
|
async function recordTokenUsage({ usageMetadata, req, userId, conversationId, model }) {
|
||||||
|
if (!usageMetadata) {
|
||||||
|
logger.debug('[GeminiImageGen] No usage metadata available for balance tracking');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appConfig = req?.config;
|
||||||
|
const balance = getBalanceConfig(appConfig);
|
||||||
|
const transactions = getTransactionsConfig(appConfig);
|
||||||
|
|
||||||
|
// Skip if neither balance nor transactions are enabled
|
||||||
|
if (!balance?.enabled && transactions?.enabled === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptTokens = usageMetadata.prompt_token_count || usageMetadata.promptTokenCount || 0;
|
||||||
|
const completionTokens =
|
||||||
|
usageMetadata.candidates_token_count || usageMetadata.candidatesTokenCount || 0;
|
||||||
|
|
||||||
|
if (promptTokens === 0 && completionTokens === 0) {
|
||||||
|
logger.debug('[GeminiImageGen] No tokens to record');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('[GeminiImageGen] Recording token usage:', {
|
||||||
|
promptTokens,
|
||||||
|
completionTokens,
|
||||||
|
model,
|
||||||
|
conversationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await spendTokens(
|
||||||
|
{
|
||||||
|
user: userId,
|
||||||
|
model,
|
||||||
|
conversationId,
|
||||||
|
context: 'image_generation',
|
||||||
|
balance,
|
||||||
|
transactions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
promptTokens,
|
||||||
|
completionTokens,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[GeminiImageGen] Error recording token usage:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates Gemini Image Generation tool
|
||||||
|
* @param {Object} fields - Configuration fields
|
||||||
|
* @returns {ReturnType<tool>} - The image generation tool
|
||||||
|
*/
|
||||||
|
function createGeminiImageTool(fields = {}) {
|
||||||
|
const override = fields.override ?? false;
|
||||||
|
|
||||||
|
if (!override && !fields.isAgent) {
|
||||||
|
throw new Error('This tool is only available for agents.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip validation during tool creation - validation happens at runtime in initializeGeminiClient
|
||||||
|
// This allows the tool to be added to agents when using Vertex AI without requiring API keys
|
||||||
|
// The actual credentials check happens when the tool is invoked
|
||||||
|
|
||||||
|
const {
|
||||||
|
req,
|
||||||
|
imageFiles = [],
|
||||||
|
processFileURL,
|
||||||
|
userId,
|
||||||
|
fileStrategy,
|
||||||
|
GEMINI_API_KEY,
|
||||||
|
GOOGLE_KEY,
|
||||||
|
// GEMINI_VERTEX_ENABLED is used for auth validation only (not used in code)
|
||||||
|
// When set as env var, it signals Vertex AI is configured and bypasses API key requirement
|
||||||
|
} = fields;
|
||||||
|
|
||||||
|
const imageOutputType = fields.imageOutputType || EImageOutputType.PNG;
|
||||||
|
|
||||||
|
const geminiImageGenTool = tool(
|
||||||
|
async ({ prompt, image_ids, aspectRatio, imageSize }, _runnableConfig) => {
|
||||||
|
if (!prompt) {
|
||||||
|
throw new Error('Missing required field: prompt');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('[GeminiImageGen] Generating image with prompt:', prompt?.substring(0, 100));
|
||||||
|
logger.debug('[GeminiImageGen] Options:', { aspectRatio, imageSize });
|
||||||
|
|
||||||
|
// Initialize Gemini client with user-provided credentials
|
||||||
|
let ai;
|
||||||
|
try {
|
||||||
|
ai = await initializeGeminiClient({
|
||||||
|
GEMINI_API_KEY,
|
||||||
|
GOOGLE_KEY,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[GeminiImageGen] Failed to initialize client:', error);
|
||||||
|
return [
|
||||||
|
[{ type: ContentTypes.TEXT, text: `Failed to initialize Gemini: ${error.message}` }],
|
||||||
|
{ content: [], file_ids: [] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build request contents
|
||||||
|
const contents = [{ text: replaceUnwantedChars(prompt) }];
|
||||||
|
|
||||||
|
// Add context images if provided
|
||||||
|
if (image_ids?.length > 0) {
|
||||||
|
const contextImages = await convertImagesToInlineData({
|
||||||
|
imageFiles,
|
||||||
|
image_ids,
|
||||||
|
req,
|
||||||
|
fileStrategy,
|
||||||
|
});
|
||||||
|
contents.push(...contextImages);
|
||||||
|
logger.debug('[GeminiImageGen] Added', contextImages.length, 'context images');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate image
|
||||||
|
let apiResponse;
|
||||||
|
const geminiModel = process.env.GEMINI_IMAGE_MODEL || 'gemini-2.5-flash-image';
|
||||||
|
try {
|
||||||
|
// Build config with optional imageConfig
|
||||||
|
const config = {
|
||||||
|
responseModalities: ['TEXT', 'IMAGE'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add imageConfig if aspectRatio or imageSize is specified
|
||||||
|
// Note: gemini-2.5-flash-image doesn't support imageSize
|
||||||
|
const supportsImageSize = !geminiModel.includes('gemini-2.5-flash-image');
|
||||||
|
if (aspectRatio || (imageSize && supportsImageSize)) {
|
||||||
|
config.imageConfig = {};
|
||||||
|
if (aspectRatio) {
|
||||||
|
config.imageConfig.aspectRatio = aspectRatio;
|
||||||
|
}
|
||||||
|
if (imageSize && supportsImageSize) {
|
||||||
|
config.imageConfig.imageSize = imageSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apiResponse = await ai.models.generateContent({
|
||||||
|
model: geminiModel,
|
||||||
|
contents,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[GeminiImageGen] API error:', error);
|
||||||
|
return [
|
||||||
|
[{ type: ContentTypes.TEXT, text: `Image generation failed: ${error.message}` }],
|
||||||
|
{ content: [], file_ids: [] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for safety blocks
|
||||||
|
const safetyBlock = checkForSafetyBlock(apiResponse);
|
||||||
|
if (safetyBlock) {
|
||||||
|
logger.warn('[GeminiImageGen] Safety block:', safetyBlock);
|
||||||
|
const errorMsg = 'Image blocked by content safety filters. Please try different content.';
|
||||||
|
return [[{ type: ContentTypes.TEXT, text: errorMsg }], { content: [], file_ids: [] }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawImageData = apiResponse.candidates?.[0]?.content?.parts?.find((p) => p.inlineData)
|
||||||
|
?.inlineData?.data;
|
||||||
|
|
||||||
|
if (!rawImageData) {
|
||||||
|
logger.warn('[GeminiImageGen] No image data in response');
|
||||||
|
return [
|
||||||
|
[{ type: ContentTypes.TEXT, text: 'No image was generated. Please try again.' }],
|
||||||
|
{ content: [], file_ids: [] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBuffer = Buffer.from(rawImageData, 'base64');
|
||||||
|
const { buffer: convertedBuffer, format: outputFormat } = await convertImageFormat(
|
||||||
|
rawBuffer,
|
||||||
|
imageOutputType,
|
||||||
|
);
|
||||||
|
const imageData = convertedBuffer.toString('base64');
|
||||||
|
const mimeType = outputFormat === 'jpeg' ? 'image/jpeg' : `image/${outputFormat}`;
|
||||||
|
|
||||||
|
logger.debug('[GeminiImageGen] Image format:', { outputFormat, mimeType });
|
||||||
|
|
||||||
|
let imageUrl;
|
||||||
|
const useLocalStorage = !fileStrategy || fileStrategy === FileSources.local;
|
||||||
|
|
||||||
|
if (useLocalStorage) {
|
||||||
|
try {
|
||||||
|
imageUrl = await saveImageLocally(imageData, outputFormat, userId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[GeminiImageGen] Local save failed:', error);
|
||||||
|
imageUrl = `data:${mimeType};base64,${imageData}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const cloudUrl = await saveToCloudStorage({
|
||||||
|
base64Data: imageData,
|
||||||
|
format: outputFormat,
|
||||||
|
processFileURL,
|
||||||
|
fileStrategy,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cloudUrl) {
|
||||||
|
imageUrl = cloudUrl;
|
||||||
|
} else {
|
||||||
|
// Fallback to local
|
||||||
|
try {
|
||||||
|
imageUrl = await saveImageLocally(imageData, outputFormat, userId);
|
||||||
|
} catch (_error) {
|
||||||
|
imageUrl = `data:${mimeType};base64,${imageData}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('[GeminiImageGen] Image URL:', imageUrl);
|
||||||
|
|
||||||
|
// For the artifact, we need a data URL (same as OpenAI)
|
||||||
|
// The local file save is for persistence, but the response needs a data URL
|
||||||
|
const dataUrl = `data:${mimeType};base64,${imageData}`;
|
||||||
|
|
||||||
|
// Return in content_and_artifact format (same as OpenAI)
|
||||||
|
const file_ids = [v4()];
|
||||||
|
const content = [
|
||||||
|
{
|
||||||
|
type: ContentTypes.IMAGE_URL,
|
||||||
|
image_url: { url: dataUrl },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const textResponse = [
|
||||||
|
{
|
||||||
|
type: ContentTypes.TEXT,
|
||||||
|
text:
|
||||||
|
displayMessage +
|
||||||
|
`\n\ngenerated_image_id: "${file_ids[0]}"` +
|
||||||
|
(image_ids?.length > 0 ? `\nreferenced_image_ids: ["${image_ids.join('", "')}"]` : ''),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Record token usage for balance tracking (don't await to avoid blocking response)
|
||||||
|
const conversationId = _runnableConfig?.configurable?.thread_id;
|
||||||
|
recordTokenUsage({
|
||||||
|
usageMetadata: apiResponse.usageMetadata,
|
||||||
|
req,
|
||||||
|
userId,
|
||||||
|
conversationId,
|
||||||
|
model: geminiModel,
|
||||||
|
}).catch((error) => {
|
||||||
|
logger.error('[GeminiImageGen] Failed to record token usage:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [textResponse, { content, file_ids }];
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...geminiToolkit.gemini_image_gen,
|
||||||
|
responseFormat: 'content_and_artifact',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return geminiImageGenTool;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export both for compatibility
|
||||||
|
module.exports = createGeminiImageTool;
|
||||||
|
module.exports.createGeminiImageTool = createGeminiImageTool;
|
||||||
|
|
@ -6,11 +6,10 @@ const { ProxyAgent } = require('undici');
|
||||||
const { tool } = require('@langchain/core/tools');
|
const { tool } = require('@langchain/core/tools');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||||
const { logAxiosError, oaiToolkit } = require('@librechat/api');
|
|
||||||
const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
|
const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
|
||||||
|
const { logAxiosError, oaiToolkit, extractBaseURL } = require('@librechat/api');
|
||||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||||
const extractBaseURL = require('~/utils/extractBaseURL');
|
const { getFiles } = require('~/models');
|
||||||
const { getFiles } = require('~/models/File');
|
|
||||||
|
|
||||||
const displayMessage =
|
const displayMessage =
|
||||||
"The tool displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.";
|
"The tool displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.";
|
||||||
|
|
@ -79,6 +78,8 @@ function createOpenAIImageTools(fields = {}) {
|
||||||
let apiKey = fields.IMAGE_GEN_OAI_API_KEY ?? getApiKey();
|
let apiKey = fields.IMAGE_GEN_OAI_API_KEY ?? getApiKey();
|
||||||
const closureConfig = { apiKey };
|
const closureConfig = { apiKey };
|
||||||
|
|
||||||
|
const imageModel = process.env.IMAGE_GEN_OAI_MODEL || 'gpt-image-1';
|
||||||
|
|
||||||
let baseURL = 'https://api.openai.com/v1/';
|
let baseURL = 'https://api.openai.com/v1/';
|
||||||
if (!override && process.env.IMAGE_GEN_OAI_BASEURL) {
|
if (!override && process.env.IMAGE_GEN_OAI_BASEURL) {
|
||||||
baseURL = extractBaseURL(process.env.IMAGE_GEN_OAI_BASEURL);
|
baseURL = extractBaseURL(process.env.IMAGE_GEN_OAI_BASEURL);
|
||||||
|
|
@ -158,7 +159,7 @@ function createOpenAIImageTools(fields = {}) {
|
||||||
|
|
||||||
resp = await openai.images.generate(
|
resp = await openai.images.generate(
|
||||||
{
|
{
|
||||||
model: 'gpt-image-1',
|
model: imageModel,
|
||||||
prompt: replaceUnwantedChars(prompt),
|
prompt: replaceUnwantedChars(prompt),
|
||||||
n: Math.min(Math.max(1, n), 10),
|
n: Math.min(Math.max(1, n), 10),
|
||||||
background,
|
background,
|
||||||
|
|
@ -240,7 +241,7 @@ Error Message: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('model', 'gpt-image-1');
|
formData.append('model', imageModel);
|
||||||
formData.append('prompt', replaceUnwantedChars(prompt));
|
formData.append('prompt', replaceUnwantedChars(prompt));
|
||||||
// TODO: `mask` support
|
// TODO: `mask` support
|
||||||
// TODO: more than 1 image support
|
// TODO: more than 1 image support
|
||||||
|
|
|
||||||
|
|
@ -232,7 +232,7 @@ class OpenWeather extends Tool {
|
||||||
|
|
||||||
if (['current_forecast', 'timestamp', 'daily_aggregation', 'overview'].includes(action)) {
|
if (['current_forecast', 'timestamp', 'daily_aggregation', 'overview'].includes(action)) {
|
||||||
if (typeof finalLat !== 'number' || typeof finalLon !== 'number') {
|
if (typeof finalLat !== 'number' || typeof finalLon !== 'number') {
|
||||||
return 'Error: lat and lon are required and must be numbers for this action (or specify \'city\').';
|
return "Error: lat and lon are required and must be numbers for this action (or specify 'city').";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -243,7 +243,7 @@ class OpenWeather extends Tool {
|
||||||
let dt;
|
let dt;
|
||||||
if (action === 'timestamp') {
|
if (action === 'timestamp') {
|
||||||
if (!date) {
|
if (!date) {
|
||||||
return 'Error: For timestamp action, a \'date\' in YYYY-MM-DD format is required.';
|
return "Error: For timestamp action, a 'date' in YYYY-MM-DD format is required.";
|
||||||
}
|
}
|
||||||
dt = this.convertDateToUnix(date);
|
dt = this.convertDateToUnix(date);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const { z } = require('zod');
|
const { z } = require('zod');
|
||||||
|
const { ProxyAgent, fetch } = require('undici');
|
||||||
const { tool } = require('@langchain/core/tools');
|
const { tool } = require('@langchain/core/tools');
|
||||||
const { getApiKey } = require('./credentials');
|
const { getApiKey } = require('./credentials');
|
||||||
|
|
||||||
|
|
@ -19,13 +20,19 @@ function createTavilySearchTool(fields = {}) {
|
||||||
...kwargs,
|
...kwargs,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch('https://api.tavily.com/search', {
|
const fetchOptions = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (process.env.PROXY) {
|
||||||
|
fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('https://api.tavily.com/search', fetchOptions);
|
||||||
|
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const { z } = require('zod');
|
const { z } = require('zod');
|
||||||
|
const { ProxyAgent, fetch } = require('undici');
|
||||||
const { Tool } = require('@langchain/core/tools');
|
const { Tool } = require('@langchain/core/tools');
|
||||||
const { getEnvironmentVariable } = require('@langchain/core/utils/env');
|
const { getEnvironmentVariable } = require('@langchain/core/utils/env');
|
||||||
|
|
||||||
|
|
@ -102,13 +103,19 @@ class TavilySearchResults extends Tool {
|
||||||
...this.kwargs,
|
...this.kwargs,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch('https://api.tavily.com/search', {
|
const fetchOptions = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (process.env.PROXY) {
|
||||||
|
fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('https://api.tavily.com/search', fetchOptions);
|
||||||
|
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
const { ytToolkit } = require('@librechat/api');
|
|
||||||
const { tool } = require('@langchain/core/tools');
|
|
||||||
const { youtube } = require('@googleapis/youtube');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
|
||||||
const { YoutubeTranscript } = require('youtube-transcript');
|
|
||||||
const { getApiKey } = require('./credentials');
|
|
||||||
|
|
||||||
function extractVideoId(url) {
|
|
||||||
const rawIdRegex = /^[a-zA-Z0-9_-]{11}$/;
|
|
||||||
if (rawIdRegex.test(url)) {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
const regex = new RegExp(
|
|
||||||
'(?:youtu\\.be/|youtube(?:\\.com)?/(?:' +
|
|
||||||
'(?:watch\\?v=)|(?:embed/)|(?:shorts/)|(?:live/)|(?:v/)|(?:/))?)' +
|
|
||||||
'([a-zA-Z0-9_-]{11})(?:\\S+)?$',
|
|
||||||
);
|
|
||||||
const match = url.match(regex);
|
|
||||||
return match ? match[1] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTranscript(transcriptResponse) {
|
|
||||||
if (!Array.isArray(transcriptResponse)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return transcriptResponse
|
|
||||||
.map((entry) => entry.text.trim())
|
|
||||||
.filter((text) => text)
|
|
||||||
.join(' ')
|
|
||||||
.replaceAll('&#39;', "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
function createYouTubeTools(fields = {}) {
|
|
||||||
const envVar = 'YOUTUBE_API_KEY';
|
|
||||||
const override = fields.override ?? false;
|
|
||||||
const apiKey = fields.apiKey ?? fields[envVar] ?? getApiKey(envVar, override);
|
|
||||||
|
|
||||||
const youtubeClient = youtube({
|
|
||||||
version: 'v3',
|
|
||||||
auth: apiKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
const searchTool = tool(async ({ query, maxResults = 5 }) => {
|
|
||||||
const response = await youtubeClient.search.list({
|
|
||||||
part: 'snippet',
|
|
||||||
q: query,
|
|
||||||
type: 'video',
|
|
||||||
maxResults: maxResults || 5,
|
|
||||||
});
|
|
||||||
const result = response.data.items.map((item) => ({
|
|
||||||
title: item.snippet.title,
|
|
||||||
description: item.snippet.description,
|
|
||||||
url: `https://www.youtube.com/watch?v=${item.id.videoId}`,
|
|
||||||
}));
|
|
||||||
return JSON.stringify(result, null, 2);
|
|
||||||
}, ytToolkit.youtube_search);
|
|
||||||
|
|
||||||
const infoTool = tool(async ({ url }) => {
|
|
||||||
const videoId = extractVideoId(url);
|
|
||||||
if (!videoId) {
|
|
||||||
throw new Error('Invalid YouTube URL or video ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await youtubeClient.videos.list({
|
|
||||||
part: 'snippet,statistics',
|
|
||||||
id: videoId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.data.items?.length) {
|
|
||||||
throw new Error('Video not found');
|
|
||||||
}
|
|
||||||
const video = response.data.items[0];
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
title: video.snippet.title,
|
|
||||||
description: video.snippet.description,
|
|
||||||
views: video.statistics.viewCount,
|
|
||||||
likes: video.statistics.likeCount,
|
|
||||||
comments: video.statistics.commentCount,
|
|
||||||
};
|
|
||||||
return JSON.stringify(result, null, 2);
|
|
||||||
}, ytToolkit.youtube_info);
|
|
||||||
|
|
||||||
const commentsTool = tool(async ({ url, maxResults = 10 }) => {
|
|
||||||
const videoId = extractVideoId(url);
|
|
||||||
if (!videoId) {
|
|
||||||
throw new Error('Invalid YouTube URL or video ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await youtubeClient.commentThreads.list({
|
|
||||||
part: 'snippet',
|
|
||||||
videoId,
|
|
||||||
maxResults: maxResults || 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = response.data.items.map((item) => ({
|
|
||||||
author: item.snippet.topLevelComment.snippet.authorDisplayName,
|
|
||||||
text: item.snippet.topLevelComment.snippet.textDisplay,
|
|
||||||
likes: item.snippet.topLevelComment.snippet.likeCount,
|
|
||||||
}));
|
|
||||||
return JSON.stringify(result, null, 2);
|
|
||||||
}, ytToolkit.youtube_comments);
|
|
||||||
|
|
||||||
const transcriptTool = tool(async ({ url }) => {
|
|
||||||
const videoId = extractVideoId(url);
|
|
||||||
if (!videoId) {
|
|
||||||
throw new Error('Invalid YouTube URL or video ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
try {
|
|
||||||
const transcript = await YoutubeTranscript.fetchTranscript(videoId, { lang: 'en' });
|
|
||||||
return parseTranscript(transcript);
|
|
||||||
} catch (e) {
|
|
||||||
logger.error(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const transcript = await YoutubeTranscript.fetchTranscript(videoId, { lang: 'de' });
|
|
||||||
return parseTranscript(transcript);
|
|
||||||
} catch (e) {
|
|
||||||
logger.error(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
const transcript = await YoutubeTranscript.fetchTranscript(videoId);
|
|
||||||
return parseTranscript(transcript);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to fetch transcript: ${error.message}`);
|
|
||||||
}
|
|
||||||
}, ytToolkit.youtube_transcript);
|
|
||||||
|
|
||||||
return [searchTool, infoTool, commentsTool, transcriptTool];
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = createYouTubeTools;
|
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
const { ProxyAgent } = require('undici');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These tests verify the proxy wrapper behavior for GeminiImageGen.
|
||||||
|
* Instead of loading the full module (which has many dependencies),
|
||||||
|
* we directly test the wrapper logic that would be applied.
|
||||||
|
*/
|
||||||
|
describe('GeminiImageGen Proxy Configuration', () => {
|
||||||
|
let originalEnv;
|
||||||
|
let originalFetch;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
originalEnv = { ...process.env };
|
||||||
|
originalFetch = globalThis.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulates the proxy wrapper that GeminiImageGen applies at module load.
|
||||||
|
* This is the same logic from GeminiImageGen.js lines 30-42.
|
||||||
|
*/
|
||||||
|
function applyProxyWrapper() {
|
||||||
|
if (process.env.PROXY) {
|
||||||
|
const _originalFetch = globalThis.fetch;
|
||||||
|
const proxyAgent = new ProxyAgent(process.env.PROXY);
|
||||||
|
|
||||||
|
globalThis.fetch = function (url, options = {}) {
|
||||||
|
const urlString = url.toString();
|
||||||
|
if (urlString.includes('googleapis.com')) {
|
||||||
|
options = { ...options, dispatcher: proxyAgent };
|
||||||
|
}
|
||||||
|
return _originalFetch.call(this, url, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should wrap globalThis.fetch when PROXY env is set', () => {
|
||||||
|
process.env.PROXY = 'http://proxy.example.com:8080';
|
||||||
|
|
||||||
|
const fetchBeforeWrap = globalThis.fetch;
|
||||||
|
|
||||||
|
applyProxyWrapper();
|
||||||
|
|
||||||
|
expect(globalThis.fetch).not.toBe(fetchBeforeWrap);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not wrap globalThis.fetch when PROXY env is not set', () => {
|
||||||
|
delete process.env.PROXY;
|
||||||
|
|
||||||
|
const fetchBeforeWrap = globalThis.fetch;
|
||||||
|
|
||||||
|
applyProxyWrapper();
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toBe(fetchBeforeWrap);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add dispatcher to googleapis.com URLs', async () => {
|
||||||
|
process.env.PROXY = 'http://proxy.example.com:8080';
|
||||||
|
|
||||||
|
let capturedOptions = null;
|
||||||
|
const mockFetch = jest.fn((url, options) => {
|
||||||
|
capturedOptions = options;
|
||||||
|
return Promise.resolve({ ok: true });
|
||||||
|
});
|
||||||
|
globalThis.fetch = mockFetch;
|
||||||
|
|
||||||
|
applyProxyWrapper();
|
||||||
|
|
||||||
|
await globalThis.fetch('https://generativelanguage.googleapis.com/v1/models', {});
|
||||||
|
|
||||||
|
expect(capturedOptions).toBeDefined();
|
||||||
|
expect(capturedOptions.dispatcher).toBeInstanceOf(ProxyAgent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not add dispatcher to non-googleapis.com URLs', async () => {
|
||||||
|
process.env.PROXY = 'http://proxy.example.com:8080';
|
||||||
|
|
||||||
|
let capturedOptions = null;
|
||||||
|
const mockFetch = jest.fn((url, options) => {
|
||||||
|
capturedOptions = options;
|
||||||
|
return Promise.resolve({ ok: true });
|
||||||
|
});
|
||||||
|
globalThis.fetch = mockFetch;
|
||||||
|
|
||||||
|
applyProxyWrapper();
|
||||||
|
|
||||||
|
await globalThis.fetch('https://api.openai.com/v1/images', {});
|
||||||
|
|
||||||
|
expect(capturedOptions).toBeDefined();
|
||||||
|
expect(capturedOptions.dispatcher).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve existing options when adding dispatcher', async () => {
|
||||||
|
process.env.PROXY = 'http://proxy.example.com:8080';
|
||||||
|
|
||||||
|
let capturedOptions = null;
|
||||||
|
const mockFetch = jest.fn((url, options) => {
|
||||||
|
capturedOptions = options;
|
||||||
|
return Promise.resolve({ ok: true });
|
||||||
|
});
|
||||||
|
globalThis.fetch = mockFetch;
|
||||||
|
|
||||||
|
applyProxyWrapper();
|
||||||
|
|
||||||
|
const customHeaders = { 'X-Custom-Header': 'test' };
|
||||||
|
await globalThis.fetch('https://aiplatform.googleapis.com/v1/models', {
|
||||||
|
headers: customHeaders,
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(capturedOptions).toBeDefined();
|
||||||
|
expect(capturedOptions.dispatcher).toBeInstanceOf(ProxyAgent);
|
||||||
|
expect(capturedOptions.headers).toEqual(customHeaders);
|
||||||
|
expect(capturedOptions.method).toBe('POST');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
|
const { fetch, ProxyAgent } = require('undici');
|
||||||
const TavilySearchResults = require('../TavilySearchResults');
|
const TavilySearchResults = require('../TavilySearchResults');
|
||||||
|
|
||||||
jest.mock('node-fetch');
|
jest.mock('undici');
|
||||||
jest.mock('@langchain/core/utils/env');
|
jest.mock('@langchain/core/utils/env');
|
||||||
|
|
||||||
describe('TavilySearchResults', () => {
|
describe('TavilySearchResults', () => {
|
||||||
|
|
@ -13,6 +14,7 @@ describe('TavilySearchResults', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.resetModules();
|
jest.resetModules();
|
||||||
|
jest.clearAllMocks();
|
||||||
process.env = {
|
process.env = {
|
||||||
...originalEnv,
|
...originalEnv,
|
||||||
TAVILY_API_KEY: mockApiKey,
|
TAVILY_API_KEY: mockApiKey,
|
||||||
|
|
@ -20,7 +22,6 @@ describe('TavilySearchResults', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
|
||||||
process.env = originalEnv;
|
process.env = originalEnv;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -35,4 +36,49 @@ describe('TavilySearchResults', () => {
|
||||||
});
|
});
|
||||||
expect(instance.apiKey).toBe(mockApiKey);
|
expect(instance.apiKey).toBe(mockApiKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('proxy support', () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: jest.fn().mockResolvedValue({ results: [] }),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetch.mockResolvedValue(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use ProxyAgent when PROXY env var is set', async () => {
|
||||||
|
const proxyUrl = 'http://proxy.example.com:8080';
|
||||||
|
process.env.PROXY = proxyUrl;
|
||||||
|
|
||||||
|
const mockProxyAgent = { type: 'proxy-agent' };
|
||||||
|
ProxyAgent.mockImplementation(() => mockProxyAgent);
|
||||||
|
|
||||||
|
const instance = new TavilySearchResults({ TAVILY_API_KEY: mockApiKey });
|
||||||
|
await instance._call({ query: 'test query' });
|
||||||
|
|
||||||
|
expect(ProxyAgent).toHaveBeenCalledWith(proxyUrl);
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'https://api.tavily.com/search',
|
||||||
|
expect.objectContaining({
|
||||||
|
dispatcher: mockProxyAgent,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not use ProxyAgent when PROXY env var is not set', async () => {
|
||||||
|
delete process.env.PROXY;
|
||||||
|
|
||||||
|
const instance = new TavilySearchResults({ TAVILY_API_KEY: mockApiKey });
|
||||||
|
await instance._call({ query: 'test query' });
|
||||||
|
|
||||||
|
expect(ProxyAgent).not.toHaveBeenCalled();
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'https://api.tavily.com/search',
|
||||||
|
expect.not.objectContaining({
|
||||||
|
dispatcher: expect.anything(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ const { logger } = require('@librechat/data-schemas');
|
||||||
const { generateShortLivedToken } = require('@librechat/api');
|
const { generateShortLivedToken } = require('@librechat/api');
|
||||||
const { Tools, EToolResources } = require('librechat-data-provider');
|
const { Tools, EToolResources } = require('librechat-data-provider');
|
||||||
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
|
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
|
||||||
const { getFiles } = require('~/models/File');
|
const { getFiles } = require('~/models');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
|
|
@ -86,7 +86,6 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
|
||||||
* @param {import('librechat-data-provider').TFile} file
|
* @param {import('librechat-data-provider').TFile} file
|
||||||
* @returns {{ file_id: string, query: string, k: number, entity_id?: string }}
|
* @returns {{ file_id: string, query: string, k: number, entity_id?: string }}
|
||||||
*/
|
*/
|
||||||
|
|
@ -135,11 +134,16 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
|
||||||
page: docInfo.metadata.page || null,
|
page: docInfo.metadata.page || null,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
// TODO: results should be sorted by relevance, not distance
|
|
||||||
.sort((a, b) => a.distance - b.distance)
|
.sort((a, b) => a.distance - b.distance)
|
||||||
// TODO: make this configurable
|
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
|
|
||||||
|
if (formattedResults.length === 0) {
|
||||||
|
return [
|
||||||
|
'No content found in the files. The files may not have been processed correctly or you may need to refine your query.',
|
||||||
|
undefined,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
const formattedString = formattedResults
|
const formattedString = formattedResults
|
||||||
.map(
|
.map(
|
||||||
(result, index) =>
|
(result, index) =>
|
||||||
|
|
@ -169,11 +173,12 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
|
||||||
? `
|
? `
|
||||||
|
|
||||||
**CITE FILE SEARCH RESULTS:**
|
**CITE FILE SEARCH RESULTS:**
|
||||||
Use anchor markers immediately after statements derived from file content. Reference the filename in your text:
|
Use the EXACT anchor markers shown below (copy them verbatim) immediately after statements derived from file content. Reference the filename in your text:
|
||||||
- File citation: "The document.pdf states that... \\ue202turn0file0"
|
- File citation: "The document.pdf states that... \\ue202turn0file0"
|
||||||
- Page reference: "According to report.docx... \\ue202turn0file1"
|
- Page reference: "According to report.docx... \\ue202turn0file1"
|
||||||
- Multi-file: "Multiple sources confirm... \\ue200\\ue202turn0file0\\ue202turn0file1\\ue201"
|
- Multi-file: "Multiple sources confirm... \\ue200\\ue202turn0file0\\ue202turn0file1\\ue201"
|
||||||
|
|
||||||
|
**CRITICAL:** Output these escape sequences EXACTLY as shown (e.g., \\ue202turn0file0). Do NOT substitute with other characters like † or similar symbols.
|
||||||
**ALWAYS mention the filename in your text before the citation marker. NEVER use markdown links or footnotes.**`
|
**ALWAYS mention the filename in your text before the citation marker. NEVER use markdown links or footnotes.**`
|
||||||
: ''
|
: ''
|
||||||
}`,
|
}`,
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
const OpenAI = require('openai');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles errors that may occur when making requests to OpenAI's API.
|
|
||||||
* It checks the instance of the error and prints a specific warning message
|
|
||||||
* to the console depending on the type of error encountered.
|
|
||||||
* It then calls an optional error callback function with the error object.
|
|
||||||
*
|
|
||||||
* @param {Error} err - The error object thrown by OpenAI API.
|
|
||||||
* @param {Function} errorCallback - A callback function that is called with the error object.
|
|
||||||
* @param {string} [context='stream'] - A string providing context where the error occurred, defaults to 'stream'.
|
|
||||||
*/
|
|
||||||
async function handleOpenAIErrors(err, errorCallback, context = 'stream') {
|
|
||||||
if (err instanceof OpenAI.APIError && err?.message?.includes('abort')) {
|
|
||||||
logger.warn(`[OpenAIClient.chatCompletion][${context}] Aborted Message`);
|
|
||||||
}
|
|
||||||
if (err instanceof OpenAI.OpenAIError && err?.message?.includes('missing finish_reason')) {
|
|
||||||
logger.warn(`[OpenAIClient.chatCompletion][${context}] Missing finish_reason`);
|
|
||||||
} else if (err instanceof OpenAI.APIError) {
|
|
||||||
logger.warn(`[OpenAIClient.chatCompletion][${context}] API error`);
|
|
||||||
} else {
|
|
||||||
logger.warn(`[OpenAIClient.chatCompletion][${context}] Unhandled error type`);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.error(err);
|
|
||||||
|
|
||||||
if (errorCallback) {
|
|
||||||
errorCallback(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = handleOpenAIErrors;
|
|
||||||
|
|
@ -10,7 +10,9 @@ const {
|
||||||
createSafeUser,
|
createSafeUser,
|
||||||
mcpToolPattern,
|
mcpToolPattern,
|
||||||
loadWebSearchAuth,
|
loadWebSearchAuth,
|
||||||
|
buildImageToolContext,
|
||||||
} = require('@librechat/api');
|
} = require('@librechat/api');
|
||||||
|
const { getMCPServersRegistry } = require('~/config');
|
||||||
const {
|
const {
|
||||||
Tools,
|
Tools,
|
||||||
Constants,
|
Constants,
|
||||||
|
|
@ -32,8 +34,8 @@ const {
|
||||||
StructuredACS,
|
StructuredACS,
|
||||||
TraversaalSearch,
|
TraversaalSearch,
|
||||||
StructuredWolfram,
|
StructuredWolfram,
|
||||||
createYouTubeTools,
|
|
||||||
TavilySearchResults,
|
TavilySearchResults,
|
||||||
|
createGeminiImageTool,
|
||||||
createOpenAIImageTools,
|
createOpenAIImageTools,
|
||||||
} = require('../');
|
} = require('../');
|
||||||
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
|
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
|
||||||
|
|
@ -182,30 +184,15 @@ const loadTools = async ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const customConstructors = {
|
const customConstructors = {
|
||||||
youtube: async (_toolContextMap) => {
|
|
||||||
const authFields = getAuthFields('youtube');
|
|
||||||
const authValues = await loadAuthValues({ userId: user, authFields });
|
|
||||||
return createYouTubeTools(authValues);
|
|
||||||
},
|
|
||||||
image_gen_oai: async (toolContextMap) => {
|
image_gen_oai: async (toolContextMap) => {
|
||||||
const authFields = getAuthFields('image_gen_oai');
|
const authFields = getAuthFields('image_gen_oai');
|
||||||
const authValues = await loadAuthValues({ userId: user, authFields });
|
const authValues = await loadAuthValues({ userId: user, authFields });
|
||||||
const imageFiles = options.tool_resources?.[EToolResources.image_edit]?.files ?? [];
|
const imageFiles = options.tool_resources?.[EToolResources.image_edit]?.files ?? [];
|
||||||
let toolContext = '';
|
const toolContext = buildImageToolContext({
|
||||||
for (let i = 0; i < imageFiles.length; i++) {
|
imageFiles,
|
||||||
const file = imageFiles[i];
|
toolName: `${EToolResources.image_edit}_oai`,
|
||||||
if (!file) {
|
contextDescription: 'image editing',
|
||||||
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) {
|
if (toolContext) {
|
||||||
toolContextMap.image_edit_oai = toolContext;
|
toolContextMap.image_edit_oai = toolContext;
|
||||||
}
|
}
|
||||||
|
|
@ -218,6 +205,28 @@ const loadTools = async ({
|
||||||
imageFiles,
|
imageFiles,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
gemini_image_gen: async (toolContextMap) => {
|
||||||
|
const authFields = getAuthFields('gemini_image_gen');
|
||||||
|
const authValues = await loadAuthValues({ userId: user, authFields });
|
||||||
|
const imageFiles = options.tool_resources?.[EToolResources.image_edit]?.files ?? [];
|
||||||
|
const toolContext = buildImageToolContext({
|
||||||
|
imageFiles,
|
||||||
|
toolName: 'gemini_image_gen',
|
||||||
|
contextDescription: 'image context',
|
||||||
|
});
|
||||||
|
if (toolContext) {
|
||||||
|
toolContextMap.gemini_image_gen = toolContext;
|
||||||
|
}
|
||||||
|
return createGeminiImageTool({
|
||||||
|
...authValues,
|
||||||
|
isAgent: !!agent,
|
||||||
|
req: options.req,
|
||||||
|
imageFiles,
|
||||||
|
processFileURL: options.processFileURL,
|
||||||
|
userId: user,
|
||||||
|
fileStrategy,
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestedTools = {};
|
const requestedTools = {};
|
||||||
|
|
@ -240,6 +249,7 @@ const loadTools = async ({
|
||||||
flux: imageGenOptions,
|
flux: imageGenOptions,
|
||||||
dalle: imageGenOptions,
|
dalle: imageGenOptions,
|
||||||
'stable-diffusion': imageGenOptions,
|
'stable-diffusion': imageGenOptions,
|
||||||
|
gemini_image_gen: imageGenOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** @type {Record<string, string>} */
|
/** @type {Record<string, string>} */
|
||||||
|
|
@ -317,14 +327,22 @@ const loadTools = async ({
|
||||||
requestedTools[tool] = async () => {
|
requestedTools[tool] = async () => {
|
||||||
toolContextMap[tool] = `# \`${tool}\`:
|
toolContextMap[tool] = `# \`${tool}\`:
|
||||||
Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
|
Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
|
||||||
1. **Execute immediately without preface** when using \`${tool}\`.
|
|
||||||
2. **After the search, begin with a brief summary** that directly addresses the query without headers or explaining your process.
|
**Execute immediately without preface.** After search, provide a brief summary addressing the query directly, then structure your response with clear Markdown formatting (## headers, lists, tables). Cite sources properly, tailor tone to query type, and provide comprehensive details.
|
||||||
3. **Structure your response clearly** using Markdown formatting (Level 2 headers for sections, lists for multiple points, tables for comparisons).
|
|
||||||
4. **Cite sources properly** according to the citation anchor format, utilizing group anchors when appropriate.
|
**CITATION FORMAT - UNICODE ESCAPE SEQUENCES ONLY:**
|
||||||
5. **Tailor your approach to the query type** (academic, news, coding, etc.) while maintaining an expert, journalistic, unbiased tone.
|
Use these EXACT escape sequences (copy verbatim): \\ue202 (before each anchor), \\ue200 (group start), \\ue201 (group end), \\ue203 (highlight start), \\ue204 (highlight end)
|
||||||
6. **Provide comprehensive information** with specific details, examples, and as much relevant context as possible from search results.
|
|
||||||
7. **Avoid moralizing language.**
|
Anchor pattern: \\ue202turn{N}{type}{index} where N=turn number, type=search|news|image|ref, index=0,1,2...
|
||||||
`.trim();
|
|
||||||
|
**Examples (copy these exactly):**
|
||||||
|
- Single: "Statement.\\ue202turn0search0"
|
||||||
|
- Multiple: "Statement.\\ue202turn0search0\\ue202turn0news1"
|
||||||
|
- Group: "Statement. \\ue200\\ue202turn0search0\\ue202turn0news1\\ue201"
|
||||||
|
- Highlight: "\\ue203Cited text.\\ue204\\ue202turn0search0"
|
||||||
|
- Image: "See photo\\ue202turn0image0."
|
||||||
|
|
||||||
|
**CRITICAL:** Output escape sequences EXACTLY as shown. Do NOT substitute with † or other symbols. Place anchors AFTER punctuation. Cite every non-obvious fact/quote. NEVER use markdown links, [1], footnotes, or HTML tags.`.trim();
|
||||||
return createSearchTool({
|
return createSearchTool({
|
||||||
...result.authResult,
|
...result.authResult,
|
||||||
onSearchResults,
|
onSearchResults,
|
||||||
|
|
@ -339,7 +357,10 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
|
||||||
/** Placeholder used for UI purposes */
|
/** Placeholder used for UI purposes */
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (serverName && options.req?.config?.mcpConfig?.[serverName] == null) {
|
const serverConfig = serverName
|
||||||
|
? await getMCPServersRegistry().getServerConfig(serverName, user)
|
||||||
|
: null;
|
||||||
|
if (!serverConfig) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`MCP server "${serverName}" for "${toolName}" tool is not configured${agent?.id != null && agent.id ? ` but attached to "${agent.id}"` : ''}`,
|
`MCP server "${serverName}" for "${toolName}" tool is not configured${agent?.id != null && agent.id ? ` but attached to "${agent.id}"` : ''}`,
|
||||||
);
|
);
|
||||||
|
|
@ -350,6 +371,7 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
|
||||||
{
|
{
|
||||||
type: 'all',
|
type: 'all',
|
||||||
serverName,
|
serverName,
|
||||||
|
config: serverConfig,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -360,6 +382,7 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
|
||||||
type: 'single',
|
type: 'single',
|
||||||
toolKey: tool,
|
toolKey: tool,
|
||||||
serverName,
|
serverName,
|
||||||
|
config: serverConfig,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -420,9 +443,11 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
|
||||||
user: safeUser,
|
user: safeUser,
|
||||||
userMCPAuthMap,
|
userMCPAuthMap,
|
||||||
res: options.res,
|
res: options.res,
|
||||||
|
streamId: options.req?._resumableStreamId || null,
|
||||||
model: agent?.model ?? model,
|
model: agent?.model ?? model,
|
||||||
serverName: config.serverName,
|
serverName: config.serverName,
|
||||||
provider: agent?.provider ?? endpoint,
|
provider: agent?.provider ?? endpoint,
|
||||||
|
config: config.config,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (config.type === 'all' && toolConfigs.length === 1) {
|
if (config.type === 'all' && toolConfigs.length === 1) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
const { validateTools, loadTools } = require('./handleTools');
|
const { validateTools, loadTools } = require('./handleTools');
|
||||||
const handleOpenAIErrors = require('./handleOpenAIErrors');
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
handleOpenAIErrors,
|
|
||||||
validateTools,
|
validateTools,
|
||||||
loadTools,
|
loadTools,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
9
api/cache/banViolation.js
vendored
9
api/cache/banViolation.js
vendored
|
|
@ -47,7 +47,16 @@ const banViolation = async (req, res, errorMessage) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
await deleteAllUserSessions({ userId: user_id });
|
await deleteAllUserSessions({ userId: user_id });
|
||||||
|
|
||||||
|
/** Clear OpenID session tokens if present */
|
||||||
|
if (req.session?.openidTokens) {
|
||||||
|
delete req.session.openidTokens;
|
||||||
|
}
|
||||||
|
|
||||||
res.clearCookie('refreshToken');
|
res.clearCookie('refreshToken');
|
||||||
|
res.clearCookie('openid_access_token');
|
||||||
|
res.clearCookie('openid_user_id');
|
||||||
|
res.clearCookie('token_provider');
|
||||||
|
|
||||||
const banLogs = getLogStores(ViolationTypes.BAN);
|
const banLogs = getLogStores(ViolationTypes.BAN);
|
||||||
const duration = errorMessage.duration || banLogs.opts.ttl;
|
const duration = errorMessage.duration || banLogs.opts.ttl;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
const { EventSource } = require('eventsource');
|
const { EventSource } = require('eventsource');
|
||||||
const { Time } = require('librechat-data-provider');
|
const { Time } = require('librechat-data-provider');
|
||||||
const { MCPManager, FlowStateManager, OAuthReconnectionManager } = require('@librechat/api');
|
const {
|
||||||
|
MCPManager,
|
||||||
|
FlowStateManager,
|
||||||
|
MCPServersRegistry,
|
||||||
|
OAuthReconnectionManager,
|
||||||
|
} = require('@librechat/api');
|
||||||
const logger = require('./winston');
|
const logger = require('./winston');
|
||||||
|
|
||||||
global.EventSource = EventSource;
|
global.EventSource = EventSource;
|
||||||
|
|
@ -23,6 +28,8 @@ function getFlowStateManager(flowsCache) {
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
logger,
|
logger,
|
||||||
|
createMCPServersRegistry: MCPServersRegistry.createInstance,
|
||||||
|
getMCPServersRegistry: MCPServersRegistry.getInstance,
|
||||||
createMCPManager: MCPManager.createInstance,
|
createMCPManager: MCPManager.createInstance,
|
||||||
getMCPManager: MCPManager.getInstance,
|
getMCPManager: MCPManager.getInstance,
|
||||||
getFlowStateManager,
|
getFlowStateManager,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,35 @@
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
const winston = require('winston');
|
const winston = require('winston');
|
||||||
require('winston-daily-rotate-file');
|
require('winston-daily-rotate-file');
|
||||||
|
|
||||||
const logDir = path.join(__dirname, '..', 'logs');
|
/**
|
||||||
|
* Determine the log directory.
|
||||||
|
* Priority:
|
||||||
|
* 1. LIBRECHAT_LOG_DIR environment variable (allows user override)
|
||||||
|
* 2. /app/logs if running in Docker (bind-mounted with correct permissions)
|
||||||
|
* 3. api/logs relative to this file (local development)
|
||||||
|
*/
|
||||||
|
const getLogDir = () => {
|
||||||
|
if (process.env.LIBRECHAT_LOG_DIR) {
|
||||||
|
return process.env.LIBRECHAT_LOG_DIR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if running in Docker container (cwd is /app)
|
||||||
|
if (process.cwd() === '/app') {
|
||||||
|
const dockerLogDir = '/app/logs';
|
||||||
|
// Ensure the directory exists
|
||||||
|
if (!fs.existsSync(dockerLogDir)) {
|
||||||
|
fs.mkdirSync(dockerLogDir, { recursive: true });
|
||||||
|
}
|
||||||
|
return dockerLogDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local development: use api/logs relative to this file
|
||||||
|
return path.join(__dirname, '..', 'logs');
|
||||||
|
};
|
||||||
|
|
||||||
|
const logDir = getLogDir();
|
||||||
|
|
||||||
const { NODE_ENV, DEBUG_LOGGING = false } = process.env;
|
const { NODE_ENV, DEBUG_LOGGING = false } = process.env;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,36 @@
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
const winston = require('winston');
|
const winston = require('winston');
|
||||||
require('winston-daily-rotate-file');
|
require('winston-daily-rotate-file');
|
||||||
const { redactFormat, redactMessage, debugTraverse, jsonTruncateFormat } = require('./parsers');
|
const { redactFormat, redactMessage, debugTraverse, jsonTruncateFormat } = require('./parsers');
|
||||||
|
|
||||||
const logDir = path.join(__dirname, '..', 'logs');
|
/**
|
||||||
|
* Determine the log directory.
|
||||||
|
* Priority:
|
||||||
|
* 1. LIBRECHAT_LOG_DIR environment variable (allows user override)
|
||||||
|
* 2. /app/logs if running in Docker (bind-mounted with correct permissions)
|
||||||
|
* 3. api/logs relative to this file (local development)
|
||||||
|
*/
|
||||||
|
const getLogDir = () => {
|
||||||
|
if (process.env.LIBRECHAT_LOG_DIR) {
|
||||||
|
return process.env.LIBRECHAT_LOG_DIR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if running in Docker container (cwd is /app)
|
||||||
|
if (process.cwd() === '/app') {
|
||||||
|
const dockerLogDir = '/app/logs';
|
||||||
|
// Ensure the directory exists
|
||||||
|
if (!fs.existsSync(dockerLogDir)) {
|
||||||
|
fs.mkdirSync(dockerLogDir, { recursive: true });
|
||||||
|
}
|
||||||
|
return dockerLogDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local development: use api/logs relative to this file
|
||||||
|
return path.join(__dirname, '..', 'logs');
|
||||||
|
};
|
||||||
|
|
||||||
|
const logDir = getLogDir();
|
||||||
|
|
||||||
const { NODE_ENV, DEBUG_LOGGING = true, CONSOLE_JSON = false, DEBUG_CONSOLE = false } = process.env;
|
const { NODE_ENV, DEBUG_LOGGING = true, CONSOLE_JSON = false, DEBUG_CONSOLE = false } = process.env;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ const { logger } = require('@librechat/data-schemas');
|
||||||
const { CacheKeys } = require('librechat-data-provider');
|
const { CacheKeys } = require('librechat-data-provider');
|
||||||
const { isEnabled, FlowStateManager } = require('@librechat/api');
|
const { isEnabled, FlowStateManager } = require('@librechat/api');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
|
const { batchResetMeiliFlags } = require('./utils');
|
||||||
|
|
||||||
const Conversation = mongoose.models.Conversation;
|
const Conversation = mongoose.models.Conversation;
|
||||||
const Message = mongoose.models.Message;
|
const Message = mongoose.models.Message;
|
||||||
|
|
@ -12,6 +13,11 @@ const searchEnabled = isEnabled(process.env.SEARCH);
|
||||||
const indexingDisabled = isEnabled(process.env.MEILI_NO_SYNC);
|
const indexingDisabled = isEnabled(process.env.MEILI_NO_SYNC);
|
||||||
let currentTimeout = null;
|
let currentTimeout = null;
|
||||||
|
|
||||||
|
const defaultSyncThreshold = 1000;
|
||||||
|
const syncThreshold = process.env.MEILI_SYNC_THRESHOLD
|
||||||
|
? parseInt(process.env.MEILI_SYNC_THRESHOLD, 10)
|
||||||
|
: defaultSyncThreshold;
|
||||||
|
|
||||||
class MeiliSearchClient {
|
class MeiliSearchClient {
|
||||||
static instance = null;
|
static instance = null;
|
||||||
|
|
||||||
|
|
@ -189,6 +195,11 @@ async function ensureFilterableAttributes(client) {
|
||||||
*/
|
*/
|
||||||
async function performSync(flowManager, flowId, flowType) {
|
async function performSync(flowManager, flowId, flowType) {
|
||||||
try {
|
try {
|
||||||
|
if (indexingDisabled === true) {
|
||||||
|
logger.info('[indexSync] Indexing is disabled, skipping...');
|
||||||
|
return { messagesSync: false, convosSync: false };
|
||||||
|
}
|
||||||
|
|
||||||
const client = MeiliSearchClient.getInstance();
|
const client = MeiliSearchClient.getInstance();
|
||||||
|
|
||||||
const { status } = await client.health();
|
const { status } = await client.health();
|
||||||
|
|
@ -196,11 +207,6 @@ async function performSync(flowManager, flowId, flowType) {
|
||||||
throw new Error('Meilisearch not available');
|
throw new Error('Meilisearch not available');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (indexingDisabled === true) {
|
|
||||||
logger.info('[indexSync] Indexing is disabled, skipping...');
|
|
||||||
return { messagesSync: false, convosSync: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ensures indexes have proper filterable attributes configured */
|
/** Ensures indexes have proper filterable attributes configured */
|
||||||
const { settingsUpdated, orphanedDocsFound: _orphanedDocsFound } =
|
const { settingsUpdated, orphanedDocsFound: _orphanedDocsFound } =
|
||||||
await ensureFilterableAttributes(client);
|
await ensureFilterableAttributes(client);
|
||||||
|
|
@ -215,33 +221,30 @@ async function performSync(flowManager, flowId, flowType) {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset sync flags to force full re-sync
|
// Reset sync flags to force full re-sync
|
||||||
await Message.collection.updateMany({ _meiliIndex: true }, { $set: { _meiliIndex: false } });
|
await batchResetMeiliFlags(Message.collection);
|
||||||
await Conversation.collection.updateMany(
|
await batchResetMeiliFlags(Conversation.collection);
|
||||||
{ _meiliIndex: true },
|
|
||||||
{ $set: { _meiliIndex: false } },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we need to sync messages
|
// Check if we need to sync messages
|
||||||
|
logger.info('[indexSync] Requesting message sync progress...');
|
||||||
const messageProgress = await Message.getSyncProgress();
|
const messageProgress = await Message.getSyncProgress();
|
||||||
if (!messageProgress.isComplete || settingsUpdated) {
|
if (!messageProgress.isComplete || settingsUpdated) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`[indexSync] Messages need syncing: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments} indexed`,
|
`[indexSync] Messages need syncing: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments} indexed`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if we should do a full sync or incremental
|
const messageCount = messageProgress.totalDocuments;
|
||||||
const messageCount = await Message.countDocuments();
|
|
||||||
const messagesIndexed = messageProgress.totalProcessed;
|
const messagesIndexed = messageProgress.totalProcessed;
|
||||||
const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10);
|
const unindexedMessages = messageCount - messagesIndexed;
|
||||||
|
|
||||||
if (messageCount - messagesIndexed > syncThreshold) {
|
if (settingsUpdated || unindexedMessages > syncThreshold) {
|
||||||
logger.info('[indexSync] Starting full message sync due to large difference');
|
logger.info(`[indexSync] Starting message sync (${unindexedMessages} unindexed)`);
|
||||||
await Message.syncWithMeili();
|
|
||||||
messagesSync = true;
|
|
||||||
} else if (messageCount !== messagesIndexed) {
|
|
||||||
logger.warn('[indexSync] Messages out of sync, performing incremental sync');
|
|
||||||
await Message.syncWithMeili();
|
await Message.syncWithMeili();
|
||||||
messagesSync = true;
|
messagesSync = true;
|
||||||
|
} else if (unindexedMessages > 0) {
|
||||||
|
logger.info(
|
||||||
|
`[indexSync] ${unindexedMessages} messages unindexed (below threshold: ${syncThreshold}, skipping)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -256,18 +259,18 @@ async function performSync(flowManager, flowId, flowType) {
|
||||||
`[indexSync] Conversations need syncing: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments} indexed`,
|
`[indexSync] Conversations need syncing: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments} indexed`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const convoCount = await Conversation.countDocuments();
|
const convoCount = convoProgress.totalDocuments;
|
||||||
const convosIndexed = convoProgress.totalProcessed;
|
const convosIndexed = convoProgress.totalProcessed;
|
||||||
const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10);
|
|
||||||
|
|
||||||
if (convoCount - convosIndexed > syncThreshold) {
|
const unindexedConvos = convoCount - convosIndexed;
|
||||||
logger.info('[indexSync] Starting full conversation sync due to large difference');
|
if (settingsUpdated || unindexedConvos > syncThreshold) {
|
||||||
await Conversation.syncWithMeili();
|
logger.info(`[indexSync] Starting convos sync (${unindexedConvos} unindexed)`);
|
||||||
convosSync = true;
|
|
||||||
} else if (convoCount !== convosIndexed) {
|
|
||||||
logger.warn('[indexSync] Convos out of sync, performing incremental sync');
|
|
||||||
await Conversation.syncWithMeili();
|
await Conversation.syncWithMeili();
|
||||||
convosSync = true;
|
convosSync = true;
|
||||||
|
} else if (unindexedConvos > 0) {
|
||||||
|
logger.info(
|
||||||
|
`[indexSync] ${unindexedConvos} convos unindexed (below threshold: ${syncThreshold}, skipping)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
465
api/db/indexSync.spec.js
Normal file
465
api/db/indexSync.spec.js
Normal file
|
|
@ -0,0 +1,465 @@
|
||||||
|
/**
|
||||||
|
* Unit tests for performSync() function in indexSync.js
|
||||||
|
*
|
||||||
|
* Tests use real mongoose with mocked model methods, only mocking external calls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
// Mock only external dependencies (not internal classes/models)
|
||||||
|
const mockLogger = {
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
debug: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockMeiliHealth = jest.fn();
|
||||||
|
const mockMeiliIndex = jest.fn();
|
||||||
|
const mockBatchResetMeiliFlags = jest.fn();
|
||||||
|
const mockIsEnabled = jest.fn();
|
||||||
|
const mockGetLogStores = jest.fn();
|
||||||
|
|
||||||
|
// Create mock models that will be reused
|
||||||
|
const createMockModel = (collectionName) => ({
|
||||||
|
collection: { name: collectionName },
|
||||||
|
getSyncProgress: jest.fn(),
|
||||||
|
syncWithMeili: jest.fn(),
|
||||||
|
countDocuments: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalMessageModel = mongoose.models.Message;
|
||||||
|
const originalConversationModel = mongoose.models.Conversation;
|
||||||
|
|
||||||
|
// Mock external modules
|
||||||
|
jest.mock('@librechat/data-schemas', () => ({
|
||||||
|
logger: mockLogger,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('meilisearch', () => ({
|
||||||
|
MeiliSearch: jest.fn(() => ({
|
||||||
|
health: mockMeiliHealth,
|
||||||
|
index: mockMeiliIndex,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./utils', () => ({
|
||||||
|
batchResetMeiliFlags: mockBatchResetMeiliFlags,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@librechat/api', () => ({
|
||||||
|
isEnabled: mockIsEnabled,
|
||||||
|
FlowStateManager: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/cache', () => ({
|
||||||
|
getLogStores: mockGetLogStores,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Set environment before module load
|
||||||
|
process.env.MEILI_HOST = 'http://localhost:7700';
|
||||||
|
process.env.MEILI_MASTER_KEY = 'test-key';
|
||||||
|
process.env.SEARCH = 'true';
|
||||||
|
process.env.MEILI_SYNC_THRESHOLD = '1000'; // Set threshold before module loads
|
||||||
|
|
||||||
|
describe('performSync() - syncThreshold logic', () => {
|
||||||
|
const ORIGINAL_ENV = process.env;
|
||||||
|
let Message;
|
||||||
|
let Conversation;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Message = createMockModel('messages');
|
||||||
|
Conversation = createMockModel('conversations');
|
||||||
|
|
||||||
|
mongoose.models.Message = Message;
|
||||||
|
mongoose.models.Conversation = Conversation;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset all mocks
|
||||||
|
jest.clearAllMocks();
|
||||||
|
// Reset modules to ensure fresh load of indexSync.js and its top-level consts (like syncThreshold)
|
||||||
|
jest.resetModules();
|
||||||
|
|
||||||
|
// Set up environment
|
||||||
|
process.env = { ...ORIGINAL_ENV };
|
||||||
|
process.env.MEILI_HOST = 'http://localhost:7700';
|
||||||
|
process.env.MEILI_MASTER_KEY = 'test-key';
|
||||||
|
process.env.SEARCH = 'true';
|
||||||
|
delete process.env.MEILI_NO_SYNC;
|
||||||
|
|
||||||
|
// Re-ensure models are available in mongoose after resetModules
|
||||||
|
// We must require mongoose again to get the fresh instance that indexSync will use
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
mongoose.models.Message = Message;
|
||||||
|
mongoose.models.Conversation = Conversation;
|
||||||
|
|
||||||
|
// Mock isEnabled
|
||||||
|
mockIsEnabled.mockImplementation((val) => val === 'true' || val === true);
|
||||||
|
|
||||||
|
// Mock MeiliSearch client responses
|
||||||
|
mockMeiliHealth.mockResolvedValue({ status: 'available' });
|
||||||
|
mockMeiliIndex.mockReturnValue({
|
||||||
|
getSettings: jest.fn().mockResolvedValue({ filterableAttributes: ['user'] }),
|
||||||
|
updateSettings: jest.fn().mockResolvedValue({}),
|
||||||
|
search: jest.fn().mockResolvedValue({ hits: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockBatchResetMeiliFlags.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = ORIGINAL_ENV;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
mongoose.models.Message = originalMessageModel;
|
||||||
|
mongoose.models.Conversation = originalConversationModel;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('triggers sync when unindexed messages exceed syncThreshold', async () => {
|
||||||
|
// Arrange: Set threshold before module load
|
||||||
|
process.env.MEILI_SYNC_THRESHOLD = '1000';
|
||||||
|
|
||||||
|
// Arrange: 1050 unindexed messages > 1000 threshold
|
||||||
|
Message.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 100,
|
||||||
|
totalDocuments: 1150, // 1050 unindexed
|
||||||
|
isComplete: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Conversation.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 50,
|
||||||
|
totalDocuments: 50,
|
||||||
|
isComplete: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
Message.syncWithMeili.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const indexSync = require('./indexSync');
|
||||||
|
await indexSync();
|
||||||
|
|
||||||
|
// Assert: No countDocuments calls
|
||||||
|
expect(Message.countDocuments).not.toHaveBeenCalled();
|
||||||
|
expect(Conversation.countDocuments).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Assert: Message sync triggered because 1050 > 1000
|
||||||
|
expect(Message.syncWithMeili).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'[indexSync] Messages need syncing: 100/1150 indexed',
|
||||||
|
);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'[indexSync] Starting message sync (1050 unindexed)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert: Conversation sync NOT triggered (already complete)
|
||||||
|
expect(Conversation.syncWithMeili).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips sync when unindexed messages are below syncThreshold', async () => {
|
||||||
|
// Arrange: 50 unindexed messages < 1000 threshold
|
||||||
|
Message.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 100,
|
||||||
|
totalDocuments: 150, // 50 unindexed
|
||||||
|
isComplete: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Conversation.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 50,
|
||||||
|
totalDocuments: 50,
|
||||||
|
isComplete: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
process.env.MEILI_SYNC_THRESHOLD = '1000';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const indexSync = require('./indexSync');
|
||||||
|
await indexSync();
|
||||||
|
|
||||||
|
// Assert: No countDocuments calls
|
||||||
|
expect(Message.countDocuments).not.toHaveBeenCalled();
|
||||||
|
expect(Conversation.countDocuments).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Assert: Message sync NOT triggered because 50 < 1000
|
||||||
|
expect(Message.syncWithMeili).not.toHaveBeenCalled();
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'[indexSync] Messages need syncing: 100/150 indexed',
|
||||||
|
);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'[indexSync] 50 messages unindexed (below threshold: 1000, skipping)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert: Conversation sync NOT triggered (already complete)
|
||||||
|
expect(Conversation.syncWithMeili).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('respects syncThreshold at boundary (exactly at threshold)', async () => {
|
||||||
|
// Arrange: 1000 unindexed messages = 1000 threshold (NOT greater than)
|
||||||
|
Message.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 100,
|
||||||
|
totalDocuments: 1100, // 1000 unindexed
|
||||||
|
isComplete: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Conversation.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 0,
|
||||||
|
totalDocuments: 0,
|
||||||
|
isComplete: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
process.env.MEILI_SYNC_THRESHOLD = '1000';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const indexSync = require('./indexSync');
|
||||||
|
await indexSync();
|
||||||
|
|
||||||
|
// Assert: No countDocuments calls
|
||||||
|
expect(Message.countDocuments).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Assert: Message sync NOT triggered because 1000 is NOT > 1000
|
||||||
|
expect(Message.syncWithMeili).not.toHaveBeenCalled();
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'[indexSync] Messages need syncing: 100/1100 indexed',
|
||||||
|
);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'[indexSync] 1000 messages unindexed (below threshold: 1000, skipping)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('triggers sync when unindexed is threshold + 1', async () => {
|
||||||
|
// Arrange: 1001 unindexed messages > 1000 threshold
|
||||||
|
Message.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 100,
|
||||||
|
totalDocuments: 1101, // 1001 unindexed
|
||||||
|
isComplete: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Conversation.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 0,
|
||||||
|
totalDocuments: 0,
|
||||||
|
isComplete: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
Message.syncWithMeili.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
process.env.MEILI_SYNC_THRESHOLD = '1000';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const indexSync = require('./indexSync');
|
||||||
|
await indexSync();
|
||||||
|
|
||||||
|
// Assert: No countDocuments calls
|
||||||
|
expect(Message.countDocuments).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Assert: Message sync triggered because 1001 > 1000
|
||||||
|
expect(Message.syncWithMeili).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'[indexSync] Messages need syncing: 100/1101 indexed',
|
||||||
|
);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'[indexSync] Starting message sync (1001 unindexed)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses totalDocuments from convoProgress for conversation sync decisions', async () => {
|
||||||
|
// Arrange: Messages complete, conversations need sync
|
||||||
|
Message.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 100,
|
||||||
|
totalDocuments: 100,
|
||||||
|
isComplete: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
Conversation.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 50,
|
||||||
|
totalDocuments: 1100, // 1050 unindexed > 1000 threshold
|
||||||
|
isComplete: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Conversation.syncWithMeili.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
process.env.MEILI_SYNC_THRESHOLD = '1000';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const indexSync = require('./indexSync');
|
||||||
|
await indexSync();
|
||||||
|
|
||||||
|
// Assert: No countDocuments calls (the optimization)
|
||||||
|
expect(Message.countDocuments).not.toHaveBeenCalled();
|
||||||
|
expect(Conversation.countDocuments).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Assert: Only conversation sync triggered
|
||||||
|
expect(Message.syncWithMeili).not.toHaveBeenCalled();
|
||||||
|
expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'[indexSync] Conversations need syncing: 50/1100 indexed',
|
||||||
|
);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'[indexSync] Starting convos sync (1050 unindexed)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips sync when collections are fully synced', async () => {
|
||||||
|
// Arrange: Everything already synced
|
||||||
|
Message.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 100,
|
||||||
|
totalDocuments: 100,
|
||||||
|
isComplete: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
Conversation.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 50,
|
||||||
|
totalDocuments: 50,
|
||||||
|
isComplete: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const indexSync = require('./indexSync');
|
||||||
|
await indexSync();
|
||||||
|
|
||||||
|
// Assert: No countDocuments calls
|
||||||
|
expect(Message.countDocuments).not.toHaveBeenCalled();
|
||||||
|
expect(Conversation.countDocuments).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Assert: No sync triggered
|
||||||
|
expect(Message.syncWithMeili).not.toHaveBeenCalled();
|
||||||
|
expect(Conversation.syncWithMeili).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Assert: Correct logs
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Messages are fully synced: 100/100');
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'[indexSync] Conversations are fully synced: 50/50',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('triggers message sync when settingsUpdated even if below syncThreshold', async () => {
|
||||||
|
// Arrange: Only 50 unindexed messages (< 1000 threshold), but settings were updated
|
||||||
|
Message.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 100,
|
||||||
|
totalDocuments: 150, // 50 unindexed
|
||||||
|
isComplete: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Conversation.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 50,
|
||||||
|
totalDocuments: 50,
|
||||||
|
isComplete: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
Message.syncWithMeili.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
// Mock settings update scenario
|
||||||
|
mockMeiliIndex.mockReturnValue({
|
||||||
|
getSettings: jest.fn().mockResolvedValue({ filterableAttributes: [] }), // No user field
|
||||||
|
updateSettings: jest.fn().mockResolvedValue({}),
|
||||||
|
search: jest.fn().mockResolvedValue({ hits: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
process.env.MEILI_SYNC_THRESHOLD = '1000';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const indexSync = require('./indexSync');
|
||||||
|
await indexSync();
|
||||||
|
|
||||||
|
// Assert: Flags were reset due to settings update
|
||||||
|
expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Message.collection);
|
||||||
|
expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Conversation.collection);
|
||||||
|
|
||||||
|
// Assert: Message sync triggered despite being below threshold (50 < 1000)
|
||||||
|
expect(Message.syncWithMeili).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...',
|
||||||
|
);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'[indexSync] Starting message sync (50 unindexed)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('triggers conversation sync when settingsUpdated even if below syncThreshold', async () => {
|
||||||
|
// Arrange: Messages complete, conversations have 50 unindexed (< 1000 threshold), but settings were updated
|
||||||
|
Message.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 100,
|
||||||
|
totalDocuments: 100,
|
||||||
|
isComplete: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
Conversation.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 50,
|
||||||
|
totalDocuments: 100, // 50 unindexed
|
||||||
|
isComplete: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Conversation.syncWithMeili.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
// Mock settings update scenario
|
||||||
|
mockMeiliIndex.mockReturnValue({
|
||||||
|
getSettings: jest.fn().mockResolvedValue({ filterableAttributes: [] }), // No user field
|
||||||
|
updateSettings: jest.fn().mockResolvedValue({}),
|
||||||
|
search: jest.fn().mockResolvedValue({ hits: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
process.env.MEILI_SYNC_THRESHOLD = '1000';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const indexSync = require('./indexSync');
|
||||||
|
await indexSync();
|
||||||
|
|
||||||
|
// Assert: Flags were reset due to settings update
|
||||||
|
expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Message.collection);
|
||||||
|
expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Conversation.collection);
|
||||||
|
|
||||||
|
// Assert: Conversation sync triggered despite being below threshold (50 < 1000)
|
||||||
|
expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...',
|
||||||
|
);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Starting convos sync (50 unindexed)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('triggers both message and conversation sync when settingsUpdated even if both below syncThreshold', async () => {
|
||||||
|
// Arrange: Set threshold before module load
|
||||||
|
process.env.MEILI_SYNC_THRESHOLD = '1000';
|
||||||
|
|
||||||
|
// Arrange: Both have documents below threshold (50 each), but settings were updated
|
||||||
|
Message.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 100,
|
||||||
|
totalDocuments: 150, // 50 unindexed
|
||||||
|
isComplete: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Conversation.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 50,
|
||||||
|
totalDocuments: 100, // 50 unindexed
|
||||||
|
isComplete: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Message.syncWithMeili.mockResolvedValue(undefined);
|
||||||
|
Conversation.syncWithMeili.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
// Mock settings update scenario
|
||||||
|
mockMeiliIndex.mockReturnValue({
|
||||||
|
getSettings: jest.fn().mockResolvedValue({ filterableAttributes: [] }), // No user field
|
||||||
|
updateSettings: jest.fn().mockResolvedValue({}),
|
||||||
|
search: jest.fn().mockResolvedValue({ hits: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const indexSync = require('./indexSync');
|
||||||
|
await indexSync();
|
||||||
|
|
||||||
|
// Assert: Flags were reset due to settings update
|
||||||
|
expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Message.collection);
|
||||||
|
expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Conversation.collection);
|
||||||
|
|
||||||
|
// Assert: Both syncs triggered despite both being below threshold
|
||||||
|
expect(Message.syncWithMeili).toHaveBeenCalledTimes(1);
|
||||||
|
expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...',
|
||||||
|
);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'[indexSync] Starting message sync (50 unindexed)',
|
||||||
|
);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Starting convos sync (50 unindexed)');
|
||||||
|
});
|
||||||
|
});
|
||||||
90
api/db/utils.js
Normal file
90
api/db/utils.js
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch update documents in chunks to avoid timeouts on weak instances
|
||||||
|
* @param {mongoose.Collection} collection - MongoDB collection
|
||||||
|
* @returns {Promise<number>} - Total modified count
|
||||||
|
* @throws {Error} - Throws if database operations fail (e.g., network issues, connection loss, permission problems)
|
||||||
|
*/
|
||||||
|
async function batchResetMeiliFlags(collection) {
|
||||||
|
const DEFAULT_BATCH_SIZE = 1000;
|
||||||
|
|
||||||
|
let BATCH_SIZE = parseEnvInt('MEILI_SYNC_BATCH_SIZE', DEFAULT_BATCH_SIZE);
|
||||||
|
if (BATCH_SIZE === 0) {
|
||||||
|
logger.warn(
|
||||||
|
`[batchResetMeiliFlags] MEILI_SYNC_BATCH_SIZE cannot be 0. Using default: ${DEFAULT_BATCH_SIZE}`,
|
||||||
|
);
|
||||||
|
BATCH_SIZE = DEFAULT_BATCH_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BATCH_DELAY_MS = parseEnvInt('MEILI_SYNC_DELAY_MS', 100);
|
||||||
|
let totalModified = 0;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (hasMore) {
|
||||||
|
const docs = await collection
|
||||||
|
.find({ expiredAt: null, _meiliIndex: true }, { projection: { _id: 1 } })
|
||||||
|
.limit(BATCH_SIZE)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
if (docs.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = docs.map((doc) => doc._id);
|
||||||
|
const result = await collection.updateMany(
|
||||||
|
{ _id: { $in: ids } },
|
||||||
|
{ $set: { _meiliIndex: false } },
|
||||||
|
);
|
||||||
|
|
||||||
|
totalModified += result.modifiedCount;
|
||||||
|
process.stdout.write(
|
||||||
|
`\r Updating ${collection.collectionName}: ${totalModified} documents...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (docs.length < BATCH_SIZE) {
|
||||||
|
hasMore = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasMore && BATCH_DELAY_MS > 0) {
|
||||||
|
await sleep(BATCH_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalModified;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to batch reset Meili flags for collection '${collection.collectionName}' after processing ${totalModified} documents: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate an environment variable as a positive integer
|
||||||
|
* @param {string} varName - Environment variable name
|
||||||
|
* @param {number} defaultValue - Default value to use if invalid or missing
|
||||||
|
* @returns {number} - Parsed value or default
|
||||||
|
*/
|
||||||
|
function parseEnvInt(varName, defaultValue) {
|
||||||
|
const value = process.env[varName];
|
||||||
|
if (!value) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseInt(value, 10);
|
||||||
|
if (isNaN(parsed) || parsed < 0) {
|
||||||
|
logger.warn(
|
||||||
|
`[batchResetMeiliFlags] Invalid value for ${varName}="${value}". Expected a positive integer. Using default: ${defaultValue}`,
|
||||||
|
);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
batchResetMeiliFlags,
|
||||||
|
};
|
||||||
521
api/db/utils.spec.js
Normal file
521
api/db/utils.spec.js
Normal file
|
|
@ -0,0 +1,521 @@
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
|
const { batchResetMeiliFlags } = require('./utils');
|
||||||
|
|
||||||
|
describe('batchResetMeiliFlags', () => {
|
||||||
|
let mongoServer;
|
||||||
|
let testCollection;
|
||||||
|
const ORIGINAL_BATCH_SIZE = process.env.MEILI_SYNC_BATCH_SIZE;
|
||||||
|
const ORIGINAL_BATCH_DELAY = process.env.MEILI_SYNC_DELAY_MS;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mongoServer = await MongoMemoryServer.create();
|
||||||
|
const mongoUri = mongoServer.getUri();
|
||||||
|
await mongoose.connect(mongoUri);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await mongoServer.stop();
|
||||||
|
|
||||||
|
// Restore original env variables
|
||||||
|
if (ORIGINAL_BATCH_SIZE !== undefined) {
|
||||||
|
process.env.MEILI_SYNC_BATCH_SIZE = ORIGINAL_BATCH_SIZE;
|
||||||
|
} else {
|
||||||
|
delete process.env.MEILI_SYNC_BATCH_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ORIGINAL_BATCH_DELAY !== undefined) {
|
||||||
|
process.env.MEILI_SYNC_DELAY_MS = ORIGINAL_BATCH_DELAY;
|
||||||
|
} else {
|
||||||
|
delete process.env.MEILI_SYNC_DELAY_MS;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create a fresh collection for each test
|
||||||
|
testCollection = mongoose.connection.db.collection('test_meili_batch');
|
||||||
|
await testCollection.deleteMany({});
|
||||||
|
|
||||||
|
// Reset env variables to defaults
|
||||||
|
delete process.env.MEILI_SYNC_BATCH_SIZE;
|
||||||
|
delete process.env.MEILI_SYNC_DELAY_MS;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (testCollection) {
|
||||||
|
await testCollection.deleteMany({});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('basic functionality', () => {
|
||||||
|
it('should reset _meiliIndex flag for documents with expiredAt: null and _meiliIndex: true', async () => {
|
||||||
|
// Insert test documents
|
||||||
|
await testCollection.insertMany([
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true, name: 'doc1' },
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true, name: 'doc2' },
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true, name: 'doc3' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await batchResetMeiliFlags(testCollection);
|
||||||
|
|
||||||
|
expect(result).toBe(3);
|
||||||
|
|
||||||
|
const updatedDocs = await testCollection.find({ _meiliIndex: false }).toArray();
|
||||||
|
expect(updatedDocs).toHaveLength(3);
|
||||||
|
|
||||||
|
const notUpdatedDocs = await testCollection.find({ _meiliIndex: true }).toArray();
|
||||||
|
expect(notUpdatedDocs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify documents with expiredAt set', async () => {
|
||||||
|
const expiredDate = new Date();
|
||||||
|
await testCollection.insertMany([
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: expiredDate, _meiliIndex: true },
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await batchResetMeiliFlags(testCollection);
|
||||||
|
|
||||||
|
expect(result).toBe(1);
|
||||||
|
|
||||||
|
const expiredDoc = await testCollection.findOne({ expiredAt: expiredDate });
|
||||||
|
expect(expiredDoc._meiliIndex).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify documents with _meiliIndex: false', async () => {
|
||||||
|
await testCollection.insertMany([
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: false },
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await batchResetMeiliFlags(testCollection);
|
||||||
|
|
||||||
|
expect(result).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 when no documents match the criteria', async () => {
|
||||||
|
await testCollection.insertMany([
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: new Date(), _meiliIndex: true },
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await batchResetMeiliFlags(testCollection);
|
||||||
|
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 when collection is empty', async () => {
|
||||||
|
const result = await batchResetMeiliFlags(testCollection);
|
||||||
|
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('batch processing', () => {
|
||||||
|
it('should process documents in batches according to MEILI_SYNC_BATCH_SIZE', async () => {
|
||||||
|
process.env.MEILI_SYNC_BATCH_SIZE = '2';
|
||||||
|
|
||||||
|
const docs = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
docs.push({
|
||||||
|
_id: new mongoose.Types.ObjectId(),
|
||||||
|
expiredAt: null,
|
||||||
|
_meiliIndex: true,
|
||||||
|
name: `doc${i}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await testCollection.insertMany(docs);
|
||||||
|
|
||||||
|
const result = await batchResetMeiliFlags(testCollection);
|
||||||
|
|
||||||
|
expect(result).toBe(5);
|
||||||
|
|
||||||
|
const updatedDocs = await testCollection.find({ _meiliIndex: false }).toArray();
|
||||||
|
expect(updatedDocs).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle large datasets with small batch sizes', async () => {
|
||||||
|
process.env.MEILI_SYNC_BATCH_SIZE = '10';
|
||||||
|
|
||||||
|
const docs = [];
|
||||||
|
for (let i = 0; i < 25; i++) {
|
||||||
|
docs.push({
|
||||||
|
_id: new mongoose.Types.ObjectId(),
|
||||||
|
expiredAt: null,
|
||||||
|
_meiliIndex: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await testCollection.insertMany(docs);
|
||||||
|
|
||||||
|
const result = await batchResetMeiliFlags(testCollection);
|
||||||
|
|
||||||
|
expect(result).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default batch size of 1000 when env variable is not set', async () => {
|
||||||
|
// Create exactly 1000 documents to verify default batch behavior
|
||||||
|
const docs = [];
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
docs.push({
|
||||||
|
_id: new mongoose.Types.ObjectId(),
|
||||||
|
expiredAt: null,
|
||||||
|
_meiliIndex: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await testCollection.insertMany(docs);
|
||||||
|
|
||||||
|
const result = await batchResetMeiliFlags(testCollection);
|
||||||
|
|
||||||
|
expect(result).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('return value', () => {
|
||||||
|
it('should return correct modified count', async () => {
|
||||||
|
await testCollection.insertMany([
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(batchResetMeiliFlags(testCollection)).resolves.toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('batch delay', () => {
|
||||||
|
it('should respect MEILI_SYNC_DELAY_MS between batches', async () => {
|
||||||
|
process.env.MEILI_SYNC_BATCH_SIZE = '2';
|
||||||
|
process.env.MEILI_SYNC_DELAY_MS = '50';
|
||||||
|
|
||||||
|
const docs = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
docs.push({
|
||||||
|
_id: new mongoose.Types.ObjectId(),
|
||||||
|
expiredAt: null,
|
||||||
|
_meiliIndex: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await testCollection.insertMany(docs);
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
await batchResetMeiliFlags(testCollection);
|
||||||
|
const endTime = Date.now();
|
||||||
|
|
||||||
|
// With 5 documents and batch size 2, we need 3 batches
|
||||||
|
// That means 2 delays between batches (not after the last one)
|
||||||
|
// So minimum time should be around 100ms (2 * 50ms)
|
||||||
|
// Using a slightly lower threshold to account for timing variations
|
||||||
|
const elapsed = endTime - startTime;
|
||||||
|
expect(elapsed).toBeGreaterThanOrEqual(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not delay when MEILI_SYNC_DELAY_MS is 0', async () => {
|
||||||
|
process.env.MEILI_SYNC_BATCH_SIZE = '2';
|
||||||
|
process.env.MEILI_SYNC_DELAY_MS = '0';
|
||||||
|
|
||||||
|
const docs = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
docs.push({
|
||||||
|
_id: new mongoose.Types.ObjectId(),
|
||||||
|
expiredAt: null,
|
||||||
|
_meiliIndex: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await testCollection.insertMany(docs);
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
await batchResetMeiliFlags(testCollection);
|
||||||
|
const endTime = Date.now();
|
||||||
|
|
||||||
|
const elapsed = endTime - startTime;
|
||||||
|
// Should complete without intentional delays, but database operations still take time
|
||||||
|
// Just verify it completes and returns the correct count
|
||||||
|
expect(elapsed).toBeLessThan(1000); // More reasonable upper bound
|
||||||
|
|
||||||
|
const result = await testCollection.countDocuments({ _meiliIndex: false });
|
||||||
|
expect(result).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not delay after the last batch', async () => {
|
||||||
|
process.env.MEILI_SYNC_BATCH_SIZE = '3';
|
||||||
|
process.env.MEILI_SYNC_DELAY_MS = '100';
|
||||||
|
|
||||||
|
// Exactly 3 documents - should fit in one batch, no delay
|
||||||
|
await testCollection.insertMany([
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await batchResetMeiliFlags(testCollection);
|
||||||
|
|
||||||
|
// Verify all 3 documents were processed in a single batch
|
||||||
|
expect(result).toBe(3);
|
||||||
|
|
||||||
|
const updatedDocs = await testCollection.countDocuments({ _meiliIndex: false });
|
||||||
|
expect(updatedDocs).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle documents without _meiliIndex field', async () => {
|
||||||
|
await testCollection.insertMany([
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null },
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await batchResetMeiliFlags(testCollection);
|
||||||
|
|
||||||
|
// Only one document has _meiliIndex: true
|
||||||
|
expect(result).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed document states correctly', async () => {
|
||||||
|
await testCollection.insertMany([
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: false },
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: new Date(), _meiliIndex: true },
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await batchResetMeiliFlags(testCollection);
|
||||||
|
|
||||||
|
expect(result).toBe(2);
|
||||||
|
|
||||||
|
const flaggedDocs = await testCollection
|
||||||
|
.find({ expiredAt: null, _meiliIndex: false })
|
||||||
|
.toArray();
|
||||||
|
expect(flaggedDocs).toHaveLength(3); // 2 were updated, 1 was already false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should throw error with context when find operation fails', async () => {
|
||||||
|
const mockCollection = {
|
||||||
|
collectionName: 'test_meili_batch',
|
||||||
|
find: jest.fn().mockReturnValue({
|
||||||
|
limit: jest.fn().mockReturnValue({
|
||||||
|
toArray: jest.fn().mockRejectedValue(new Error('Network error')),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(batchResetMeiliFlags(mockCollection)).rejects.toThrow(
|
||||||
|
"Failed to batch reset Meili flags for collection 'test_meili_batch' after processing 0 documents: Network error",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error with context when updateMany operation fails', async () => {
|
||||||
|
const mockCollection = {
|
||||||
|
collectionName: 'test_meili_batch',
|
||||||
|
find: jest.fn().mockReturnValue({
|
||||||
|
limit: jest.fn().mockReturnValue({
|
||||||
|
toArray: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
{ _id: new mongoose.Types.ObjectId() },
|
||||||
|
{ _id: new mongoose.Types.ObjectId() },
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
updateMany: jest.fn().mockRejectedValue(new Error('Connection lost')),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(batchResetMeiliFlags(mockCollection)).rejects.toThrow(
|
||||||
|
"Failed to batch reset Meili flags for collection 'test_meili_batch' after processing 0 documents: Connection lost",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include documents processed count in error when failure occurs mid-batch', async () => {
|
||||||
|
// Set batch size to 2 to force multiple batches
|
||||||
|
process.env.MEILI_SYNC_BATCH_SIZE = '2';
|
||||||
|
process.env.MEILI_SYNC_DELAY_MS = '0'; // No delay for faster test
|
||||||
|
|
||||||
|
let findCallCount = 0;
|
||||||
|
let updateCallCount = 0;
|
||||||
|
|
||||||
|
const mockCollection = {
|
||||||
|
collectionName: 'test_meili_batch',
|
||||||
|
find: jest.fn().mockReturnValue({
|
||||||
|
limit: jest.fn().mockReturnValue({
|
||||||
|
toArray: jest.fn().mockImplementation(() => {
|
||||||
|
findCallCount++;
|
||||||
|
// Return 2 documents for first two calls (to keep loop going)
|
||||||
|
// Return 2 documents for third call (to trigger third update which will fail)
|
||||||
|
if (findCallCount <= 3) {
|
||||||
|
return Promise.resolve([
|
||||||
|
{ _id: new mongoose.Types.ObjectId() },
|
||||||
|
{ _id: new mongoose.Types.ObjectId() },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// Should not reach here due to error
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
updateMany: jest.fn().mockImplementation(() => {
|
||||||
|
updateCallCount++;
|
||||||
|
if (updateCallCount === 1) {
|
||||||
|
return Promise.resolve({ modifiedCount: 2 });
|
||||||
|
} else if (updateCallCount === 2) {
|
||||||
|
return Promise.resolve({ modifiedCount: 2 });
|
||||||
|
} else {
|
||||||
|
return Promise.reject(new Error('Database timeout'));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(batchResetMeiliFlags(mockCollection)).rejects.toThrow(
|
||||||
|
"Failed to batch reset Meili flags for collection 'test_meili_batch' after processing 4 documents: Database timeout",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use collection.collectionName in error messages', async () => {
|
||||||
|
const mockCollection = {
|
||||||
|
collectionName: 'messages',
|
||||||
|
find: jest.fn().mockReturnValue({
|
||||||
|
limit: jest.fn().mockReturnValue({
|
||||||
|
toArray: jest.fn().mockRejectedValue(new Error('Permission denied')),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(batchResetMeiliFlags(mockCollection)).rejects.toThrow(
|
||||||
|
"Failed to batch reset Meili flags for collection 'messages' after processing 0 documents: Permission denied",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('environment variable validation', () => {
|
||||||
|
let warnSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock logger.warn to track warning calls
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (warnSpy) {
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log warning and use default when MEILI_SYNC_BATCH_SIZE is not a number', async () => {
|
||||||
|
process.env.MEILI_SYNC_BATCH_SIZE = 'abc';
|
||||||
|
|
||||||
|
await testCollection.insertMany([
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await batchResetMeiliFlags(testCollection);
|
||||||
|
|
||||||
|
expect(result).toBe(1);
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Invalid value for MEILI_SYNC_BATCH_SIZE="abc"'),
|
||||||
|
);
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Using default: 1000'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log warning and use default when MEILI_SYNC_DELAY_MS is not a number', async () => {
|
||||||
|
process.env.MEILI_SYNC_DELAY_MS = 'xyz';
|
||||||
|
|
||||||
|
await testCollection.insertMany([
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await batchResetMeiliFlags(testCollection);
|
||||||
|
|
||||||
|
expect(result).toBe(1);
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Invalid value for MEILI_SYNC_DELAY_MS="xyz"'),
|
||||||
|
);
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Using default: 100'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log warning and use default when MEILI_SYNC_BATCH_SIZE is negative', async () => {
|
||||||
|
process.env.MEILI_SYNC_BATCH_SIZE = '-50';
|
||||||
|
|
||||||
|
await testCollection.insertMany([
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await batchResetMeiliFlags(testCollection);
|
||||||
|
|
||||||
|
expect(result).toBe(1);
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Invalid value for MEILI_SYNC_BATCH_SIZE="-50"'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log warning and use default when MEILI_SYNC_DELAY_MS is negative', async () => {
|
||||||
|
process.env.MEILI_SYNC_DELAY_MS = '-100';
|
||||||
|
|
||||||
|
await testCollection.insertMany([
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await batchResetMeiliFlags(testCollection);
|
||||||
|
|
||||||
|
expect(result).toBe(1);
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Invalid value for MEILI_SYNC_DELAY_MS="-100"'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept valid positive integer values without warnings', async () => {
|
||||||
|
process.env.MEILI_SYNC_BATCH_SIZE = '500';
|
||||||
|
process.env.MEILI_SYNC_DELAY_MS = '50';
|
||||||
|
|
||||||
|
await testCollection.insertMany([
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await batchResetMeiliFlags(testCollection);
|
||||||
|
|
||||||
|
expect(result).toBe(1);
|
||||||
|
expect(warnSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log warning and use default when MEILI_SYNC_BATCH_SIZE is zero', async () => {
|
||||||
|
process.env.MEILI_SYNC_BATCH_SIZE = '0';
|
||||||
|
|
||||||
|
await testCollection.insertMany([
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await batchResetMeiliFlags(testCollection);
|
||||||
|
|
||||||
|
expect(result).toBe(1);
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('MEILI_SYNC_BATCH_SIZE cannot be 0. Using default: 1000'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept zero as a valid value for MEILI_SYNC_DELAY_MS without warnings', async () => {
|
||||||
|
process.env.MEILI_SYNC_DELAY_MS = '0';
|
||||||
|
|
||||||
|
await testCollection.insertMany([
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await batchResetMeiliFlags(testCollection);
|
||||||
|
|
||||||
|
expect(result).toBe(1);
|
||||||
|
expect(warnSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not log warnings when environment variables are not set', async () => {
|
||||||
|
delete process.env.MEILI_SYNC_BATCH_SIZE;
|
||||||
|
delete process.env.MEILI_SYNC_DELAY_MS;
|
||||||
|
|
||||||
|
await testCollection.insertMany([
|
||||||
|
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await batchResetMeiliFlags(testCollection);
|
||||||
|
|
||||||
|
expect(result).toBe(1);
|
||||||
|
expect(warnSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,11 +4,7 @@ module.exports = {
|
||||||
roots: ['<rootDir>'],
|
roots: ['<rootDir>'],
|
||||||
coverageDirectory: 'coverage',
|
coverageDirectory: 'coverage',
|
||||||
testTimeout: 30000, // 30 seconds timeout for all tests
|
testTimeout: 30000, // 30 seconds timeout for all tests
|
||||||
setupFiles: [
|
setupFiles: ['./test/jestSetup.js', './test/__mocks__/logger.js'],
|
||||||
'./test/jestSetup.js',
|
|
||||||
'./test/__mocks__/logger.js',
|
|
||||||
'./test/__mocks__/fetchEventSource.js',
|
|
||||||
],
|
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'~/(.*)': '<rootDir>/$1',
|
'~/(.*)': '<rootDir>/$1',
|
||||||
'~/data/auth.json': '<rootDir>/__mocks__/auth.mock.json',
|
'~/data/auth.json': '<rootDir>/__mocks__/auth.mock.json',
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
function mergeSort(arr, compareFn) {
|
|
||||||
if (arr.length <= 1) {
|
|
||||||
return arr;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mid = Math.floor(arr.length / 2);
|
|
||||||
const leftArr = arr.slice(0, mid);
|
|
||||||
const rightArr = arr.slice(mid);
|
|
||||||
|
|
||||||
return merge(mergeSort(leftArr, compareFn), mergeSort(rightArr, compareFn), compareFn);
|
|
||||||
}
|
|
||||||
|
|
||||||
function merge(leftArr, rightArr, compareFn) {
|
|
||||||
const result = [];
|
|
||||||
let leftIndex = 0;
|
|
||||||
let rightIndex = 0;
|
|
||||||
|
|
||||||
while (leftIndex < leftArr.length && rightIndex < rightArr.length) {
|
|
||||||
if (compareFn(leftArr[leftIndex], rightArr[rightIndex]) < 0) {
|
|
||||||
result.push(leftArr[leftIndex++]);
|
|
||||||
} else {
|
|
||||||
result.push(rightArr[rightIndex++]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.concat(leftArr.slice(leftIndex)).concat(rightArr.slice(rightIndex));
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = mergeSort;
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
const cleanUpPrimaryKeyValue = (value) => {
|
|
||||||
// For Bing convoId handling
|
|
||||||
return value.replace(/--/g, '|');
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
cleanUpPrimaryKeyValue,
|
|
||||||
};
|
|
||||||
|
|
@ -1,20 +1,50 @@
|
||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { ResourceType, SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider');
|
const { getCustomEndpointConfig } = require('@librechat/api');
|
||||||
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_all, mcp_delimiter } =
|
const {
|
||||||
require('librechat-data-provider').Constants;
|
Tools,
|
||||||
|
SystemRoles,
|
||||||
|
ResourceType,
|
||||||
|
actionDelimiter,
|
||||||
|
isAgentsEndpoint,
|
||||||
|
isEphemeralAgentId,
|
||||||
|
encodeEphemeralAgentId,
|
||||||
|
} = require('librechat-data-provider');
|
||||||
|
const { mcp_all, mcp_delimiter } = require('librechat-data-provider').Constants;
|
||||||
const {
|
const {
|
||||||
removeAgentFromAllProjects,
|
removeAgentFromAllProjects,
|
||||||
removeAgentIdsFromProject,
|
removeAgentIdsFromProject,
|
||||||
addAgentIdsToProject,
|
addAgentIdsToProject,
|
||||||
getProjectByName,
|
|
||||||
} = require('./Project');
|
} = require('./Project');
|
||||||
const { removeAllPermissions } = require('~/server/services/PermissionService');
|
const { removeAllPermissions } = require('~/server/services/PermissionService');
|
||||||
const { getMCPServerTools } = require('~/server/services/Config');
|
const { getMCPServerTools } = require('~/server/services/Config');
|
||||||
const { Agent, AclEntry } = require('~/db/models');
|
const { Agent, AclEntry, User } = require('~/db/models');
|
||||||
const { getActions } = require('./Action');
|
const { getActions } = require('./Action');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts unique MCP server names from tools array
|
||||||
|
* Tools format: "toolName_mcp_serverName" or "sys__server__sys_mcp_serverName"
|
||||||
|
* @param {string[]} tools - Array of tool identifiers
|
||||||
|
* @returns {string[]} Array of unique MCP server names
|
||||||
|
*/
|
||||||
|
const extractMCPServerNames = (tools) => {
|
||||||
|
if (!tools || !Array.isArray(tools)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const serverNames = new Set();
|
||||||
|
for (const tool of tools) {
|
||||||
|
if (!tool || !tool.includes(mcp_delimiter)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parts = tool.split(mcp_delimiter);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
serverNames.add(parts[parts.length - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(serverNames);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an agent with the provided data.
|
* Create an agent with the provided data.
|
||||||
* @param {Object} agentData - The agent data to create.
|
* @param {Object} agentData - The agent data to create.
|
||||||
|
|
@ -34,6 +64,7 @@ const createAgent = async (agentData) => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
category: agentData.category || 'general',
|
category: agentData.category || 'general',
|
||||||
|
mcpServerNames: extractMCPServerNames(agentData.tools),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (await Agent.create(initialAgentData)).toObject();
|
return (await Agent.create(initialAgentData)).toObject();
|
||||||
|
|
@ -68,7 +99,7 @@ const getAgents = async (searchParameter) => await Agent.find(searchParameter).l
|
||||||
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
|
* @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 loadEphemeralAgent = async ({ req, spec, agent_id, endpoint, model_parameters: _m }) => {
|
const loadEphemeralAgent = async ({ req, spec, endpoint, model_parameters: _m }) => {
|
||||||
const { model, ...model_parameters } = _m;
|
const { model, ...model_parameters } = _m;
|
||||||
const modelSpecs = req.config?.modelSpecs?.list;
|
const modelSpecs = req.config?.modelSpecs?.list;
|
||||||
/** @type {TModelSpec | null} */
|
/** @type {TModelSpec | null} */
|
||||||
|
|
@ -115,8 +146,28 @@ const loadEphemeralAgent = async ({ req, spec, agent_id, endpoint, model_paramet
|
||||||
}
|
}
|
||||||
|
|
||||||
const instructions = req.body.promptPrefix;
|
const instructions = req.body.promptPrefix;
|
||||||
|
|
||||||
|
// Get endpoint config for modelDisplayLabel fallback
|
||||||
|
const appConfig = req.config;
|
||||||
|
let endpointConfig = appConfig?.endpoints?.[endpoint];
|
||||||
|
if (!isAgentsEndpoint(endpoint) && !endpointConfig) {
|
||||||
|
try {
|
||||||
|
endpointConfig = getCustomEndpointConfig({ endpoint, appConfig });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[loadEphemeralAgent] Error getting custom endpoint config', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For ephemeral agents, use modelLabel if provided, then model spec's label,
|
||||||
|
// then modelDisplayLabel from endpoint config, otherwise empty string to show model name
|
||||||
|
const sender =
|
||||||
|
model_parameters?.modelLabel ?? modelSpec?.label ?? endpointConfig?.modelDisplayLabel ?? '';
|
||||||
|
|
||||||
|
// Encode ephemeral agent ID with endpoint, model, and computed sender for display
|
||||||
|
const ephemeralId = encodeEphemeralAgentId({ endpoint, model, sender });
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
id: agent_id,
|
id: ephemeralId,
|
||||||
instructions,
|
instructions,
|
||||||
provider: endpoint,
|
provider: endpoint,
|
||||||
model_parameters,
|
model_parameters,
|
||||||
|
|
@ -145,8 +196,8 @@ const loadAgent = async ({ req, spec, agent_id, endpoint, model_parameters }) =>
|
||||||
if (!agent_id) {
|
if (!agent_id) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (agent_id === EPHEMERAL_AGENT_ID) {
|
if (isEphemeralAgentId(agent_id)) {
|
||||||
return await loadEphemeralAgent({ req, spec, agent_id, endpoint, model_parameters });
|
return await loadEphemeralAgent({ req, spec, endpoint, model_parameters });
|
||||||
}
|
}
|
||||||
const agent = await getAgent({
|
const agent = await getAgent({
|
||||||
id: agent_id,
|
id: agent_id,
|
||||||
|
|
@ -354,6 +405,13 @@ const updateAgent = async (searchParameter, updateData, options = {}) => {
|
||||||
} = currentAgent.toObject();
|
} = currentAgent.toObject();
|
||||||
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
|
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
|
||||||
|
|
||||||
|
// Sync mcpServerNames when tools are updated
|
||||||
|
if (directUpdates.tools !== undefined) {
|
||||||
|
const mcpServerNames = extractMCPServerNames(directUpdates.tools);
|
||||||
|
directUpdates.mcpServerNames = mcpServerNames;
|
||||||
|
updateData.mcpServerNames = mcpServerNames; // Also update the original updateData
|
||||||
|
}
|
||||||
|
|
||||||
let actionsHash = null;
|
let actionsHash = null;
|
||||||
|
|
||||||
// Generate actions hash if agent has actions
|
// Generate actions hash if agent has actions
|
||||||
|
|
@ -535,6 +593,19 @@ const deleteAgent = async (searchParameter) => {
|
||||||
resourceType: ResourceType.AGENT,
|
resourceType: ResourceType.AGENT,
|
||||||
resourceId: agent._id,
|
resourceId: agent._id,
|
||||||
});
|
});
|
||||||
|
try {
|
||||||
|
await Agent.updateMany({ 'edges.to': agent.id }, { $pull: { edges: { to: agent.id } } });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[deleteAgent] Error removing agent from handoff edges', error);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await User.updateMany(
|
||||||
|
{ 'favorites.agentId': agent.id },
|
||||||
|
{ $pull: { favorites: { agentId: agent.id } } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[deleteAgent] Error removing agent from user favorites', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return agent;
|
return agent;
|
||||||
};
|
};
|
||||||
|
|
@ -564,6 +635,15 @@ const deleteUserAgents = async (userId) => {
|
||||||
resourceId: { $in: agentObjectIds },
|
resourceId: { $in: agentObjectIds },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await User.updateMany(
|
||||||
|
{ 'favorites.agentId': { $in: agentIds } },
|
||||||
|
{ $pull: { favorites: { agentId: { $in: agentIds } } } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[deleteUserAgents] Error removing agents from user favorites', error);
|
||||||
|
}
|
||||||
|
|
||||||
await Agent.deleteMany({ author: userId });
|
await Agent.deleteMany({ author: userId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[deleteUserAgents] General error:', error);
|
logger.error('[deleteUserAgents] General error:', error);
|
||||||
|
|
@ -670,59 +750,6 @@ const getListAgentsByAccess = async ({
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all agents.
|
|
||||||
* @deprecated Use getListAgentsByAccess for ACL-aware agent listing
|
|
||||||
* @param {Object} searchParameter - The search parameters to find matching agents.
|
|
||||||
* @param {string} searchParameter.author - The user ID of the agent's author.
|
|
||||||
* @returns {Promise<Object>} A promise that resolves to an object containing the agents data and pagination info.
|
|
||||||
*/
|
|
||||||
const getListAgents = async (searchParameter) => {
|
|
||||||
const { author, ...otherParams } = searchParameter;
|
|
||||||
|
|
||||||
let query = Object.assign({ author }, otherParams);
|
|
||||||
|
|
||||||
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, ['agentIds']);
|
|
||||||
if (globalProject && (globalProject.agentIds?.length ?? 0) > 0) {
|
|
||||||
const globalQuery = { id: { $in: globalProject.agentIds }, ...otherParams };
|
|
||||||
delete globalQuery.author;
|
|
||||||
query = { $or: [globalQuery, query] };
|
|
||||||
}
|
|
||||||
const agents = (
|
|
||||||
await Agent.find(query, {
|
|
||||||
id: 1,
|
|
||||||
_id: 1,
|
|
||||||
name: 1,
|
|
||||||
avatar: 1,
|
|
||||||
author: 1,
|
|
||||||
projectIds: 1,
|
|
||||||
description: 1,
|
|
||||||
// @deprecated - isCollaborative replaced by ACL permissions
|
|
||||||
isCollaborative: 1,
|
|
||||||
category: 1,
|
|
||||||
}).lean()
|
|
||||||
).map((agent) => {
|
|
||||||
if (agent.author?.toString() !== author) {
|
|
||||||
delete agent.author;
|
|
||||||
}
|
|
||||||
if (agent.author) {
|
|
||||||
agent.author = agent.author.toString();
|
|
||||||
}
|
|
||||||
return agent;
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasMore = agents.length > 0;
|
|
||||||
const firstId = agents.length > 0 ? agents[0].id : null;
|
|
||||||
const lastId = agents.length > 0 ? agents[agents.length - 1].id : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: agents,
|
|
||||||
has_more: hasMore,
|
|
||||||
first_id: firstId,
|
|
||||||
last_id: lastId,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the projects associated with an agent, adding and removing project IDs as specified.
|
* Updates the projects associated with an agent, adding and removing project IDs as specified.
|
||||||
* This function also updates the corresponding projects to include or exclude the agent ID.
|
* This function also updates the corresponding projects to include or exclude the agent ID.
|
||||||
|
|
@ -888,12 +915,11 @@ module.exports = {
|
||||||
updateAgent,
|
updateAgent,
|
||||||
deleteAgent,
|
deleteAgent,
|
||||||
deleteUserAgents,
|
deleteUserAgents,
|
||||||
getListAgents,
|
|
||||||
revertAgentVersion,
|
revertAgentVersion,
|
||||||
updateAgentProjects,
|
updateAgentProjects,
|
||||||
|
countPromotedAgents,
|
||||||
addAgentResourceFile,
|
addAgentResourceFile,
|
||||||
getListAgentsByAccess,
|
getListAgentsByAccess,
|
||||||
removeAgentResourceFiles,
|
removeAgentResourceFiles,
|
||||||
generateActionMetadataHash,
|
generateActionMetadataHash,
|
||||||
countPromotedAgents,
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -22,17 +22,17 @@ const {
|
||||||
createAgent,
|
createAgent,
|
||||||
updateAgent,
|
updateAgent,
|
||||||
deleteAgent,
|
deleteAgent,
|
||||||
getListAgents,
|
deleteUserAgents,
|
||||||
getListAgentsByAccess,
|
|
||||||
revertAgentVersion,
|
revertAgentVersion,
|
||||||
updateAgentProjects,
|
updateAgentProjects,
|
||||||
addAgentResourceFile,
|
addAgentResourceFile,
|
||||||
|
getListAgentsByAccess,
|
||||||
removeAgentResourceFiles,
|
removeAgentResourceFiles,
|
||||||
generateActionMetadataHash,
|
generateActionMetadataHash,
|
||||||
} = require('./Agent');
|
} = require('./Agent');
|
||||||
const permissionService = require('~/server/services/PermissionService');
|
const permissionService = require('~/server/services/PermissionService');
|
||||||
const { getCachedTools, getMCPServerTools } = require('~/server/services/Config');
|
const { getCachedTools, getMCPServerTools } = require('~/server/services/Config');
|
||||||
const { AclEntry } = require('~/db/models');
|
const { AclEntry, User } = require('~/db/models');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {import('mongoose').Model<import('@librechat/data-schemas').IAgent>}
|
* @type {import('mongoose').Model<import('@librechat/data-schemas').IAgent>}
|
||||||
|
|
@ -59,6 +59,7 @@ describe('models/Agent', () => {
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await Agent.deleteMany({});
|
await Agent.deleteMany({});
|
||||||
|
await User.deleteMany({});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should add tool_resource to tools if missing', async () => {
|
test('should add tool_resource to tools if missing', async () => {
|
||||||
|
|
@ -532,43 +533,531 @@ describe('models/Agent', () => {
|
||||||
expect(aclEntriesAfter).toHaveLength(0);
|
expect(aclEntriesAfter).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should list agents by author', async () => {
|
test('should remove handoff edges referencing deleted agent from other agents', async () => {
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
const targetAgentId = `agent_${uuidv4()}`;
|
||||||
|
const sourceAgentId = `agent_${uuidv4()}`;
|
||||||
|
|
||||||
|
// Create target agent (handoff destination)
|
||||||
|
await createAgent({
|
||||||
|
id: targetAgentId,
|
||||||
|
name: 'Target Agent',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create source agent with handoff edge to target
|
||||||
|
await createAgent({
|
||||||
|
id: sourceAgentId,
|
||||||
|
name: 'Source Agent',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
from: sourceAgentId,
|
||||||
|
to: targetAgentId,
|
||||||
|
edgeType: 'handoff',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify edge exists before deletion
|
||||||
|
const sourceAgentBefore = await getAgent({ id: sourceAgentId });
|
||||||
|
expect(sourceAgentBefore.edges).toHaveLength(1);
|
||||||
|
expect(sourceAgentBefore.edges[0].to).toBe(targetAgentId);
|
||||||
|
|
||||||
|
// Delete the target agent
|
||||||
|
await deleteAgent({ id: targetAgentId });
|
||||||
|
|
||||||
|
// Verify the edge is removed from source agent
|
||||||
|
const sourceAgentAfter = await getAgent({ id: sourceAgentId });
|
||||||
|
expect(sourceAgentAfter.edges).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should remove agent from user favorites when agent is deleted', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
const userId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
// Create agent
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Agent To Delete',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create user with the agent in favorites
|
||||||
|
await User.create({
|
||||||
|
_id: userId,
|
||||||
|
name: 'Test User',
|
||||||
|
email: `test-${uuidv4()}@example.com`,
|
||||||
|
provider: 'local',
|
||||||
|
favorites: [{ agentId: agentId }, { model: 'gpt-4', endpoint: 'openAI' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify user has agent in favorites
|
||||||
|
const userBefore = await User.findById(userId);
|
||||||
|
expect(userBefore.favorites).toHaveLength(2);
|
||||||
|
expect(userBefore.favorites.some((f) => f.agentId === agentId)).toBe(true);
|
||||||
|
|
||||||
|
// Delete the agent
|
||||||
|
await deleteAgent({ id: agentId });
|
||||||
|
|
||||||
|
// Verify agent is deleted
|
||||||
|
const agentAfterDelete = await getAgent({ id: agentId });
|
||||||
|
expect(agentAfterDelete).toBeNull();
|
||||||
|
|
||||||
|
// Verify agent is removed from user favorites
|
||||||
|
const userAfter = await User.findById(userId);
|
||||||
|
expect(userAfter.favorites).toHaveLength(1);
|
||||||
|
expect(userAfter.favorites.some((f) => f.agentId === agentId)).toBe(false);
|
||||||
|
expect(userAfter.favorites.some((f) => f.model === 'gpt-4')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should remove agent from multiple users favorites when agent is deleted', async () => {
|
||||||
|
const agentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
const user1Id = new mongoose.Types.ObjectId();
|
||||||
|
const user2Id = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
// Create agent
|
||||||
|
await createAgent({
|
||||||
|
id: agentId,
|
||||||
|
name: 'Agent To Delete',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create two users with the agent in favorites
|
||||||
|
await User.create({
|
||||||
|
_id: user1Id,
|
||||||
|
name: 'Test User 1',
|
||||||
|
email: `test1-${uuidv4()}@example.com`,
|
||||||
|
provider: 'local',
|
||||||
|
favorites: [{ agentId: agentId }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await User.create({
|
||||||
|
_id: user2Id,
|
||||||
|
name: 'Test User 2',
|
||||||
|
email: `test2-${uuidv4()}@example.com`,
|
||||||
|
provider: 'local',
|
||||||
|
favorites: [{ agentId: agentId }, { agentId: `agent_${uuidv4()}` }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete the agent
|
||||||
|
await deleteAgent({ id: agentId });
|
||||||
|
|
||||||
|
// Verify agent is removed from both users' favorites
|
||||||
|
const user1After = await User.findById(user1Id);
|
||||||
|
const user2After = await User.findById(user2Id);
|
||||||
|
|
||||||
|
expect(user1After.favorites).toHaveLength(0);
|
||||||
|
expect(user2After.favorites).toHaveLength(1);
|
||||||
|
expect(user2After.favorites.some((f) => f.agentId === agentId)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should preserve other agents in database when one agent is deleted', async () => {
|
||||||
|
const agentToDeleteId = `agent_${uuidv4()}`;
|
||||||
|
const agentToKeep1Id = `agent_${uuidv4()}`;
|
||||||
|
const agentToKeep2Id = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
// Create multiple agents
|
||||||
|
await createAgent({
|
||||||
|
id: agentToDeleteId,
|
||||||
|
name: 'Agent To Delete',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createAgent({
|
||||||
|
id: agentToKeep1Id,
|
||||||
|
name: 'Agent To Keep 1',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createAgent({
|
||||||
|
id: agentToKeep2Id,
|
||||||
|
name: 'Agent To Keep 2',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify all agents exist
|
||||||
|
expect(await getAgent({ id: agentToDeleteId })).not.toBeNull();
|
||||||
|
expect(await getAgent({ id: agentToKeep1Id })).not.toBeNull();
|
||||||
|
expect(await getAgent({ id: agentToKeep2Id })).not.toBeNull();
|
||||||
|
|
||||||
|
// Delete one agent
|
||||||
|
await deleteAgent({ id: agentToDeleteId });
|
||||||
|
|
||||||
|
// Verify only the deleted agent is removed, others remain intact
|
||||||
|
expect(await getAgent({ id: agentToDeleteId })).toBeNull();
|
||||||
|
const keptAgent1 = await getAgent({ id: agentToKeep1Id });
|
||||||
|
const keptAgent2 = await getAgent({ id: agentToKeep2Id });
|
||||||
|
expect(keptAgent1).not.toBeNull();
|
||||||
|
expect(keptAgent1.name).toBe('Agent To Keep 1');
|
||||||
|
expect(keptAgent2).not.toBeNull();
|
||||||
|
expect(keptAgent2.name).toBe('Agent To Keep 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should preserve other agents in user favorites when one agent is deleted', async () => {
|
||||||
|
const agentToDeleteId = `agent_${uuidv4()}`;
|
||||||
|
const agentToKeep1Id = `agent_${uuidv4()}`;
|
||||||
|
const agentToKeep2Id = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
const userId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
// Create multiple agents
|
||||||
|
await createAgent({
|
||||||
|
id: agentToDeleteId,
|
||||||
|
name: 'Agent To Delete',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createAgent({
|
||||||
|
id: agentToKeep1Id,
|
||||||
|
name: 'Agent To Keep 1',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createAgent({
|
||||||
|
id: agentToKeep2Id,
|
||||||
|
name: 'Agent To Keep 2',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create user with all three agents in favorites
|
||||||
|
await User.create({
|
||||||
|
_id: userId,
|
||||||
|
name: 'Test User',
|
||||||
|
email: `test-${uuidv4()}@example.com`,
|
||||||
|
provider: 'local',
|
||||||
|
favorites: [
|
||||||
|
{ agentId: agentToDeleteId },
|
||||||
|
{ agentId: agentToKeep1Id },
|
||||||
|
{ agentId: agentToKeep2Id },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify user has all three agents in favorites
|
||||||
|
const userBefore = await User.findById(userId);
|
||||||
|
expect(userBefore.favorites).toHaveLength(3);
|
||||||
|
|
||||||
|
// Delete one agent
|
||||||
|
await deleteAgent({ id: agentToDeleteId });
|
||||||
|
|
||||||
|
// Verify only the deleted agent is removed from favorites
|
||||||
|
const userAfter = await User.findById(userId);
|
||||||
|
expect(userAfter.favorites).toHaveLength(2);
|
||||||
|
expect(userAfter.favorites.some((f) => f.agentId === agentToDeleteId)).toBe(false);
|
||||||
|
expect(userAfter.favorites.some((f) => f.agentId === agentToKeep1Id)).toBe(true);
|
||||||
|
expect(userAfter.favorites.some((f) => f.agentId === agentToKeep2Id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not affect users who do not have deleted agent in favorites', async () => {
|
||||||
|
const agentToDeleteId = `agent_${uuidv4()}`;
|
||||||
|
const otherAgentId = `agent_${uuidv4()}`;
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
const userWithDeletedAgentId = new mongoose.Types.ObjectId();
|
||||||
|
const userWithoutDeletedAgentId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
// Create agents
|
||||||
|
await createAgent({
|
||||||
|
id: agentToDeleteId,
|
||||||
|
name: 'Agent To Delete',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createAgent({
|
||||||
|
id: otherAgentId,
|
||||||
|
name: 'Other Agent',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create user with the agent to be deleted
|
||||||
|
await User.create({
|
||||||
|
_id: userWithDeletedAgentId,
|
||||||
|
name: 'User With Deleted Agent',
|
||||||
|
email: `user1-${uuidv4()}@example.com`,
|
||||||
|
provider: 'local',
|
||||||
|
favorites: [{ agentId: agentToDeleteId }, { model: 'gpt-4', endpoint: 'openAI' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create user without the agent to be deleted
|
||||||
|
await User.create({
|
||||||
|
_id: userWithoutDeletedAgentId,
|
||||||
|
name: 'User Without Deleted Agent',
|
||||||
|
email: `user2-${uuidv4()}@example.com`,
|
||||||
|
provider: 'local',
|
||||||
|
favorites: [{ agentId: otherAgentId }, { model: 'claude-3', endpoint: 'anthropic' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete the agent
|
||||||
|
await deleteAgent({ id: agentToDeleteId });
|
||||||
|
|
||||||
|
// Verify user with deleted agent has it removed
|
||||||
|
const userWithDeleted = await User.findById(userWithDeletedAgentId);
|
||||||
|
expect(userWithDeleted.favorites).toHaveLength(1);
|
||||||
|
expect(userWithDeleted.favorites.some((f) => f.agentId === agentToDeleteId)).toBe(false);
|
||||||
|
expect(userWithDeleted.favorites.some((f) => f.model === 'gpt-4')).toBe(true);
|
||||||
|
|
||||||
|
// Verify user without deleted agent is completely unaffected
|
||||||
|
const userWithoutDeleted = await User.findById(userWithoutDeletedAgentId);
|
||||||
|
expect(userWithoutDeleted.favorites).toHaveLength(2);
|
||||||
|
expect(userWithoutDeleted.favorites.some((f) => f.agentId === otherAgentId)).toBe(true);
|
||||||
|
expect(userWithoutDeleted.favorites.some((f) => f.model === 'claude-3')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should remove all user agents from favorites when deleteUserAgents is called', async () => {
|
||||||
const authorId = new mongoose.Types.ObjectId();
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
const otherAuthorId = new mongoose.Types.ObjectId();
|
const otherAuthorId = new mongoose.Types.ObjectId();
|
||||||
|
const userId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
const agentIds = [];
|
const agent1Id = `agent_${uuidv4()}`;
|
||||||
for (let i = 0; i < 5; i++) {
|
const agent2Id = `agent_${uuidv4()}`;
|
||||||
const id = `agent_${uuidv4()}`;
|
const otherAuthorAgentId = `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++) {
|
// Create agents by the author to be deleted
|
||||||
await createAgent({
|
await createAgent({
|
||||||
id: `other_agent_${uuidv4()}`,
|
id: agent1Id,
|
||||||
name: `Other Agent ${i}`,
|
name: 'Author Agent 1',
|
||||||
provider: 'test',
|
provider: 'test',
|
||||||
model: 'test-model',
|
model: 'test-model',
|
||||||
author: otherAuthorId,
|
author: authorId,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const result = await getListAgents({ author: authorId.toString() });
|
await createAgent({
|
||||||
|
id: agent2Id,
|
||||||
|
name: 'Author Agent 2',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
// Create agent by different author (should not be deleted)
|
||||||
expect(result.data).toBeDefined();
|
await createAgent({
|
||||||
expect(result.data).toHaveLength(5);
|
id: otherAuthorAgentId,
|
||||||
expect(result.has_more).toBe(true);
|
name: 'Other Author Agent',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: otherAuthorId,
|
||||||
|
});
|
||||||
|
|
||||||
for (const agent of result.data) {
|
// Create user with all agents in favorites
|
||||||
expect(agent.author).toBe(authorId.toString());
|
await User.create({
|
||||||
}
|
_id: userId,
|
||||||
|
name: 'Test User',
|
||||||
|
email: `test-${uuidv4()}@example.com`,
|
||||||
|
provider: 'local',
|
||||||
|
favorites: [
|
||||||
|
{ agentId: agent1Id },
|
||||||
|
{ agentId: agent2Id },
|
||||||
|
{ agentId: otherAuthorAgentId },
|
||||||
|
{ model: 'gpt-4', endpoint: 'openAI' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify user has all favorites
|
||||||
|
const userBefore = await User.findById(userId);
|
||||||
|
expect(userBefore.favorites).toHaveLength(4);
|
||||||
|
|
||||||
|
// Delete all agents by the author
|
||||||
|
await deleteUserAgents(authorId.toString());
|
||||||
|
|
||||||
|
// Verify author's agents are deleted from database
|
||||||
|
expect(await getAgent({ id: agent1Id })).toBeNull();
|
||||||
|
expect(await getAgent({ id: agent2Id })).toBeNull();
|
||||||
|
|
||||||
|
// Verify other author's agent still exists
|
||||||
|
expect(await getAgent({ id: otherAuthorAgentId })).not.toBeNull();
|
||||||
|
|
||||||
|
// Verify user favorites: author's agents removed, others remain
|
||||||
|
const userAfter = await User.findById(userId);
|
||||||
|
expect(userAfter.favorites).toHaveLength(2);
|
||||||
|
expect(userAfter.favorites.some((f) => f.agentId === agent1Id)).toBe(false);
|
||||||
|
expect(userAfter.favorites.some((f) => f.agentId === agent2Id)).toBe(false);
|
||||||
|
expect(userAfter.favorites.some((f) => f.agentId === otherAuthorAgentId)).toBe(true);
|
||||||
|
expect(userAfter.favorites.some((f) => f.model === 'gpt-4')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle deleteUserAgents when agents are in multiple users favorites', async () => {
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
const user1Id = new mongoose.Types.ObjectId();
|
||||||
|
const user2Id = new mongoose.Types.ObjectId();
|
||||||
|
const user3Id = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
const agent1Id = `agent_${uuidv4()}`;
|
||||||
|
const agent2Id = `agent_${uuidv4()}`;
|
||||||
|
const unrelatedAgentId = `agent_${uuidv4()}`;
|
||||||
|
|
||||||
|
// Create agents by the author
|
||||||
|
await createAgent({
|
||||||
|
id: agent1Id,
|
||||||
|
name: 'Author Agent 1',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createAgent({
|
||||||
|
id: agent2Id,
|
||||||
|
name: 'Author Agent 2',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create users with various favorites configurations
|
||||||
|
await User.create({
|
||||||
|
_id: user1Id,
|
||||||
|
name: 'User 1',
|
||||||
|
email: `user1-${uuidv4()}@example.com`,
|
||||||
|
provider: 'local',
|
||||||
|
favorites: [{ agentId: agent1Id }, { agentId: agent2Id }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await User.create({
|
||||||
|
_id: user2Id,
|
||||||
|
name: 'User 2',
|
||||||
|
email: `user2-${uuidv4()}@example.com`,
|
||||||
|
provider: 'local',
|
||||||
|
favorites: [{ agentId: agent1Id }, { model: 'claude-3', endpoint: 'anthropic' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await User.create({
|
||||||
|
_id: user3Id,
|
||||||
|
name: 'User 3',
|
||||||
|
email: `user3-${uuidv4()}@example.com`,
|
||||||
|
provider: 'local',
|
||||||
|
favorites: [{ agentId: unrelatedAgentId }, { model: 'gpt-4', endpoint: 'openAI' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete all agents by the author
|
||||||
|
await deleteUserAgents(authorId.toString());
|
||||||
|
|
||||||
|
// Verify all users' favorites are correctly updated
|
||||||
|
const user1After = await User.findById(user1Id);
|
||||||
|
expect(user1After.favorites).toHaveLength(0);
|
||||||
|
|
||||||
|
const user2After = await User.findById(user2Id);
|
||||||
|
expect(user2After.favorites).toHaveLength(1);
|
||||||
|
expect(user2After.favorites.some((f) => f.agentId === agent1Id)).toBe(false);
|
||||||
|
expect(user2After.favorites.some((f) => f.model === 'claude-3')).toBe(true);
|
||||||
|
|
||||||
|
// User 3 should be completely unaffected
|
||||||
|
const user3After = await User.findById(user3Id);
|
||||||
|
expect(user3After.favorites).toHaveLength(2);
|
||||||
|
expect(user3After.favorites.some((f) => f.agentId === unrelatedAgentId)).toBe(true);
|
||||||
|
expect(user3After.favorites.some((f) => f.model === 'gpt-4')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle deleteUserAgents when user has no agents', async () => {
|
||||||
|
const authorWithNoAgentsId = new mongoose.Types.ObjectId();
|
||||||
|
const otherAuthorId = new mongoose.Types.ObjectId();
|
||||||
|
const userId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
const existingAgentId = `agent_${uuidv4()}`;
|
||||||
|
|
||||||
|
// Create agent by different author
|
||||||
|
await createAgent({
|
||||||
|
id: existingAgentId,
|
||||||
|
name: 'Existing Agent',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: otherAuthorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create user with favorites
|
||||||
|
await User.create({
|
||||||
|
_id: userId,
|
||||||
|
name: 'Test User',
|
||||||
|
email: `test-${uuidv4()}@example.com`,
|
||||||
|
provider: 'local',
|
||||||
|
favorites: [{ agentId: existingAgentId }, { model: 'gpt-4', endpoint: 'openAI' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete agents for user with no agents (should be a no-op)
|
||||||
|
await deleteUserAgents(authorWithNoAgentsId.toString());
|
||||||
|
|
||||||
|
// Verify existing agent still exists
|
||||||
|
expect(await getAgent({ id: existingAgentId })).not.toBeNull();
|
||||||
|
|
||||||
|
// Verify user favorites are unchanged
|
||||||
|
const userAfter = await User.findById(userId);
|
||||||
|
expect(userAfter.favorites).toHaveLength(2);
|
||||||
|
expect(userAfter.favorites.some((f) => f.agentId === existingAgentId)).toBe(true);
|
||||||
|
expect(userAfter.favorites.some((f) => f.model === 'gpt-4')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle deleteUserAgents when agents are not in any favorites', async () => {
|
||||||
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
const userId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
|
const agent1Id = `agent_${uuidv4()}`;
|
||||||
|
const agent2Id = `agent_${uuidv4()}`;
|
||||||
|
|
||||||
|
// Create agents by the author
|
||||||
|
await createAgent({
|
||||||
|
id: agent1Id,
|
||||||
|
name: 'Agent 1',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createAgent({
|
||||||
|
id: agent2Id,
|
||||||
|
name: 'Agent 2',
|
||||||
|
provider: 'test',
|
||||||
|
model: 'test-model',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create user with favorites that don't include these agents
|
||||||
|
await User.create({
|
||||||
|
_id: userId,
|
||||||
|
name: 'Test User',
|
||||||
|
email: `test-${uuidv4()}@example.com`,
|
||||||
|
provider: 'local',
|
||||||
|
favorites: [{ model: 'gpt-4', endpoint: 'openAI' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify agents exist
|
||||||
|
expect(await getAgent({ id: agent1Id })).not.toBeNull();
|
||||||
|
expect(await getAgent({ id: agent2Id })).not.toBeNull();
|
||||||
|
|
||||||
|
// Delete all agents by the author
|
||||||
|
await deleteUserAgents(authorId.toString());
|
||||||
|
|
||||||
|
// Verify agents are deleted
|
||||||
|
expect(await getAgent({ id: agent1Id })).toBeNull();
|
||||||
|
expect(await getAgent({ id: agent2Id })).toBeNull();
|
||||||
|
|
||||||
|
// Verify user favorites are unchanged
|
||||||
|
const userAfter = await User.findById(userId);
|
||||||
|
expect(userAfter.favorites).toHaveLength(1);
|
||||||
|
expect(userAfter.favorites.some((f) => f.model === 'gpt-4')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should update agent projects', async () => {
|
test('should update agent projects', async () => {
|
||||||
|
|
@ -690,26 +1179,6 @@ describe('models/Agent', () => {
|
||||||
expect(result).toBe(expected);
|
expect(result).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle getListAgents with invalid author format', async () => {
|
|
||||||
try {
|
|
||||||
const result = await getListAgents({ author: 'invalid-object-id' });
|
|
||||||
expect(result.data).toEqual([]);
|
|
||||||
} catch (error) {
|
|
||||||
expect(error).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle getListAgents with no agents', async () => {
|
|
||||||
const authorId = new mongoose.Types.ObjectId();
|
|
||||||
const result = await getListAgents({ author: authorId.toString() });
|
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result.data).toEqual([]);
|
|
||||||
expect(result.has_more).toBe(false);
|
|
||||||
expect(result.first_id).toBeNull();
|
|
||||||
expect(result.last_id).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle updateAgentProjects with non-existent agent', async () => {
|
test('should handle updateAgentProjects with non-existent agent', async () => {
|
||||||
const nonExistentId = `agent_${uuidv4()}`;
|
const nonExistentId = `agent_${uuidv4()}`;
|
||||||
const userId = new mongoose.Types.ObjectId();
|
const userId = new mongoose.Types.ObjectId();
|
||||||
|
|
@ -1960,7 +2429,8 @@ describe('models/Agent', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
expect(result.id).toBe(EPHEMERAL_AGENT_ID);
|
// Ephemeral agent ID is encoded with endpoint and model
|
||||||
|
expect(result.id).toBe('openai__gpt-4');
|
||||||
expect(result.instructions).toBe('Test instructions');
|
expect(result.instructions).toBe('Test instructions');
|
||||||
expect(result.provider).toBe('openai');
|
expect(result.provider).toBe('openai');
|
||||||
expect(result.model).toBe('gpt-4');
|
expect(result.model).toBe('gpt-4');
|
||||||
|
|
@ -1978,7 +2448,7 @@ describe('models/Agent', () => {
|
||||||
const mockReq = { user: { id: 'user123' } };
|
const mockReq = { user: { id: 'user123' } };
|
||||||
const result = await loadAgent({
|
const result = await loadAgent({
|
||||||
req: mockReq,
|
req: mockReq,
|
||||||
agent_id: 'non_existent_agent',
|
agent_id: 'agent_non_existent',
|
||||||
endpoint: 'openai',
|
endpoint: 'openai',
|
||||||
model_parameters: { model: 'gpt-4' },
|
model_parameters: { model: 'gpt-4' },
|
||||||
});
|
});
|
||||||
|
|
@ -2105,7 +2575,7 @@ describe('models/Agent', () => {
|
||||||
test('should handle loadAgent with malformed req object', async () => {
|
test('should handle loadAgent with malformed req object', async () => {
|
||||||
const result = await loadAgent({
|
const result = await loadAgent({
|
||||||
req: null,
|
req: null,
|
||||||
agent_id: 'test',
|
agent_id: 'agent_test',
|
||||||
endpoint: 'openai',
|
endpoint: 'openai',
|
||||||
model_parameters: { model: 'gpt-4' },
|
model_parameters: { model: 'gpt-4' },
|
||||||
});
|
});
|
||||||
|
|
@ -2322,17 +2792,6 @@ describe('models/Agent', () => {
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle getListAgents with no agents', async () => {
|
|
||||||
const authorId = new mongoose.Types.ObjectId();
|
|
||||||
const result = await getListAgents({ author: authorId.toString() });
|
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result.data).toEqual([]);
|
|
||||||
expect(result.has_more).toBe(false);
|
|
||||||
expect(result.first_id).toBeNull();
|
|
||||||
expect(result.last_id).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle updateAgent with MongoDB operators mixed with direct updates', async () => {
|
test('should handle updateAgent with MongoDB operators mixed with direct updates', async () => {
|
||||||
const agentId = `agent_${uuidv4()}`;
|
const agentId = `agent_${uuidv4()}`;
|
||||||
const authorId = new mongoose.Types.ObjectId();
|
const authorId = new mongoose.Types.ObjectId();
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ const getConvo = async (user, conversationId) => {
|
||||||
return await Conversation.findOne({ user, conversationId }).lean();
|
return await Conversation.findOne({ user, conversationId }).lean();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[getConvo] Error getting single conversation', error);
|
logger.error('[getConvo] Error getting single conversation', error);
|
||||||
return { message: 'Error getting single conversation' };
|
throw new Error('Error getting single conversation');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -151,13 +151,21 @@ module.exports = {
|
||||||
const result = await Conversation.bulkWrite(bulkOps);
|
const result = await Conversation.bulkWrite(bulkOps);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[saveBulkConversations] Error saving conversations in bulk', error);
|
logger.error('[bulkSaveConvos] Error saving conversations in bulk', error);
|
||||||
throw new Error('Failed to save conversations in bulk.');
|
throw new Error('Failed to save conversations in bulk.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getConvosByCursor: async (
|
getConvosByCursor: async (
|
||||||
user,
|
user,
|
||||||
{ cursor, limit = 25, isArchived = false, tags, search, order = 'desc' } = {},
|
{
|
||||||
|
cursor,
|
||||||
|
limit = 25,
|
||||||
|
isArchived = false,
|
||||||
|
tags,
|
||||||
|
search,
|
||||||
|
sortBy = 'updatedAt',
|
||||||
|
sortDirection = 'desc',
|
||||||
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
const filters = [{ user }];
|
const filters = [{ user }];
|
||||||
if (isArchived) {
|
if (isArchived) {
|
||||||
|
|
@ -184,35 +192,79 @@ module.exports = {
|
||||||
filters.push({ conversationId: { $in: matchingIds } });
|
filters.push({ conversationId: { $in: matchingIds } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[getConvosByCursor] Error during meiliSearch', error);
|
logger.error('[getConvosByCursor] Error during meiliSearch', error);
|
||||||
return { message: 'Error during meiliSearch' };
|
throw new Error('Error during meiliSearch');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validSortFields = ['title', 'createdAt', 'updatedAt'];
|
||||||
|
if (!validSortFields.includes(sortBy)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid sortBy field: ${sortBy}. Must be one of ${validSortFields.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const finalSortBy = sortBy;
|
||||||
|
const finalSortDirection = sortDirection === 'asc' ? 'asc' : 'desc';
|
||||||
|
|
||||||
|
let cursorFilter = null;
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
filters.push({ updatedAt: { $lt: new Date(cursor) } });
|
try {
|
||||||
|
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString());
|
||||||
|
const { primary, secondary } = decoded;
|
||||||
|
const primaryValue = finalSortBy === 'title' ? primary : new Date(primary);
|
||||||
|
const secondaryValue = new Date(secondary);
|
||||||
|
const op = finalSortDirection === 'asc' ? '$gt' : '$lt';
|
||||||
|
|
||||||
|
cursorFilter = {
|
||||||
|
$or: [
|
||||||
|
{ [finalSortBy]: { [op]: primaryValue } },
|
||||||
|
{
|
||||||
|
[finalSortBy]: primaryValue,
|
||||||
|
updatedAt: { [op]: secondaryValue },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('[getConvosByCursor] Invalid cursor format, starting from beginning');
|
||||||
|
}
|
||||||
|
if (cursorFilter) {
|
||||||
|
filters.push(cursorFilter);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = filters.length === 1 ? filters[0] : { $and: filters };
|
const query = filters.length === 1 ? filters[0] : { $and: filters };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const sortOrder = finalSortDirection === 'asc' ? 1 : -1;
|
||||||
|
const sortObj = { [finalSortBy]: sortOrder };
|
||||||
|
|
||||||
|
if (finalSortBy !== 'updatedAt') {
|
||||||
|
sortObj.updatedAt = sortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
const convos = await Conversation.find(query)
|
const convos = await Conversation.find(query)
|
||||||
.select(
|
.select(
|
||||||
'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL',
|
'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL',
|
||||||
)
|
)
|
||||||
.sort({ updatedAt: order === 'asc' ? 1 : -1 })
|
.sort(sortObj)
|
||||||
.limit(limit + 1)
|
.limit(limit + 1)
|
||||||
.lean();
|
.lean();
|
||||||
|
|
||||||
let nextCursor = null;
|
let nextCursor = null;
|
||||||
if (convos.length > limit) {
|
if (convos.length > limit) {
|
||||||
const lastConvo = convos.pop();
|
convos.pop(); // Remove extra item used to detect next page
|
||||||
nextCursor = lastConvo.updatedAt.toISOString();
|
// Create cursor from the last RETURNED item (not the popped one)
|
||||||
|
const lastReturned = convos[convos.length - 1];
|
||||||
|
const primaryValue = lastReturned[finalSortBy];
|
||||||
|
const primaryStr = finalSortBy === 'title' ? primaryValue : primaryValue.toISOString();
|
||||||
|
const secondaryStr = lastReturned.updatedAt.toISOString();
|
||||||
|
const composite = { primary: primaryStr, secondary: secondaryStr };
|
||||||
|
nextCursor = Buffer.from(JSON.stringify(composite)).toString('base64');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { conversations: convos, nextCursor };
|
return { conversations: convos, nextCursor };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[getConvosByCursor] Error getting conversations', error);
|
logger.error('[getConvosByCursor] Error getting conversations', error);
|
||||||
return { message: 'Error getting conversations' };
|
throw new Error('Error getting conversations');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => {
|
getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => {
|
||||||
|
|
@ -240,8 +292,9 @@ module.exports = {
|
||||||
const limited = filtered.slice(0, limit + 1);
|
const limited = filtered.slice(0, limit + 1);
|
||||||
let nextCursor = null;
|
let nextCursor = null;
|
||||||
if (limited.length > limit) {
|
if (limited.length > limit) {
|
||||||
const lastConvo = limited.pop();
|
limited.pop(); // Remove extra item used to detect next page
|
||||||
nextCursor = lastConvo.updatedAt.toISOString();
|
// Create cursor from the last RETURNED item (not the popped one)
|
||||||
|
nextCursor = limited[limited.length - 1].updatedAt.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const convoMap = {};
|
const convoMap = {};
|
||||||
|
|
@ -252,7 +305,7 @@ module.exports = {
|
||||||
return { conversations: limited, nextCursor, convoMap };
|
return { conversations: limited, nextCursor, convoMap };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[getConvosQueried] Error getting conversations', error);
|
logger.error('[getConvosQueried] Error getting conversations', error);
|
||||||
return { message: 'Error fetching conversations' };
|
throw new Error('Error fetching conversations');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getConvo,
|
getConvo,
|
||||||
|
|
@ -269,7 +322,7 @@ module.exports = {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[getConvoTitle] Error getting conversation title', error);
|
logger.error('[getConvoTitle] Error getting conversation title', error);
|
||||||
return { message: 'Error getting conversation title' };
|
throw new Error('Error getting conversation title');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -567,4 +567,267 @@ describe('Conversation Operations', () => {
|
||||||
await mongoose.connect(mongoServer.getUri());
|
await mongoose.connect(mongoServer.getUri());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getConvosByCursor pagination', () => {
|
||||||
|
/**
|
||||||
|
* Helper to create conversations with specific timestamps
|
||||||
|
* Uses collection.insertOne to bypass Mongoose timestamps entirely
|
||||||
|
*/
|
||||||
|
const createConvoWithTimestamps = async (index, createdAt, updatedAt) => {
|
||||||
|
const conversationId = uuidv4();
|
||||||
|
// Use collection-level insert to bypass Mongoose timestamps
|
||||||
|
await Conversation.collection.insertOne({
|
||||||
|
conversationId,
|
||||||
|
user: 'user123',
|
||||||
|
title: `Conversation ${index}`,
|
||||||
|
endpoint: EModelEndpoint.openAI,
|
||||||
|
expiredAt: null,
|
||||||
|
isArchived: false,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
});
|
||||||
|
return Conversation.findOne({ conversationId }).lean();
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should not skip conversations at page boundaries', async () => {
|
||||||
|
// Create 30 conversations to ensure pagination (limit is 25)
|
||||||
|
const baseTime = new Date('2026-01-01T00:00:00.000Z');
|
||||||
|
const convos = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const updatedAt = new Date(baseTime.getTime() - i * 60000); // Each 1 minute apart
|
||||||
|
const convo = await createConvoWithTimestamps(i, updatedAt, updatedAt);
|
||||||
|
convos.push(convo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch first page
|
||||||
|
const page1 = await getConvosByCursor('user123', { limit: 25 });
|
||||||
|
|
||||||
|
expect(page1.conversations).toHaveLength(25);
|
||||||
|
expect(page1.nextCursor).toBeTruthy();
|
||||||
|
|
||||||
|
// Fetch second page using cursor
|
||||||
|
const page2 = await getConvosByCursor('user123', {
|
||||||
|
limit: 25,
|
||||||
|
cursor: page1.nextCursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should get remaining 5 conversations
|
||||||
|
expect(page2.conversations).toHaveLength(5);
|
||||||
|
expect(page2.nextCursor).toBeNull();
|
||||||
|
|
||||||
|
// Verify no duplicates and no gaps
|
||||||
|
const allIds = [
|
||||||
|
...page1.conversations.map((c) => c.conversationId),
|
||||||
|
...page2.conversations.map((c) => c.conversationId),
|
||||||
|
];
|
||||||
|
const uniqueIds = new Set(allIds);
|
||||||
|
|
||||||
|
expect(uniqueIds.size).toBe(30); // All 30 conversations accounted for
|
||||||
|
expect(allIds.length).toBe(30); // No duplicates
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include conversation at exact page boundary (item 26 bug fix)', async () => {
|
||||||
|
// This test specifically verifies the fix for the bug where item 26
|
||||||
|
// (the first item that should appear on page 2) was being skipped
|
||||||
|
|
||||||
|
const baseTime = new Date('2026-01-01T12:00:00.000Z');
|
||||||
|
|
||||||
|
// Create exactly 26 conversations
|
||||||
|
const convos = [];
|
||||||
|
for (let i = 0; i < 26; i++) {
|
||||||
|
const updatedAt = new Date(baseTime.getTime() - i * 60000);
|
||||||
|
const convo = await createConvoWithTimestamps(i, updatedAt, updatedAt);
|
||||||
|
convos.push(convo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 26th conversation (index 25) should be on page 2
|
||||||
|
const item26 = convos[25];
|
||||||
|
|
||||||
|
// Fetch first page with limit 25
|
||||||
|
const page1 = await getConvosByCursor('user123', { limit: 25 });
|
||||||
|
|
||||||
|
expect(page1.conversations).toHaveLength(25);
|
||||||
|
expect(page1.nextCursor).toBeTruthy();
|
||||||
|
|
||||||
|
// Item 26 should NOT be in page 1
|
||||||
|
const page1Ids = page1.conversations.map((c) => c.conversationId);
|
||||||
|
expect(page1Ids).not.toContain(item26.conversationId);
|
||||||
|
|
||||||
|
// Fetch second page
|
||||||
|
const page2 = await getConvosByCursor('user123', {
|
||||||
|
limit: 25,
|
||||||
|
cursor: page1.nextCursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Item 26 MUST be in page 2 (this was the bug - it was being skipped)
|
||||||
|
expect(page2.conversations).toHaveLength(1);
|
||||||
|
expect(page2.conversations[0].conversationId).toBe(item26.conversationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sort by updatedAt DESC by default', async () => {
|
||||||
|
// Create conversations with different updatedAt times
|
||||||
|
// Note: createdAt is older but updatedAt varies
|
||||||
|
const convo1 = await createConvoWithTimestamps(
|
||||||
|
1,
|
||||||
|
new Date('2026-01-01T00:00:00.000Z'), // oldest created
|
||||||
|
new Date('2026-01-03T00:00:00.000Z'), // most recently updated
|
||||||
|
);
|
||||||
|
|
||||||
|
const convo2 = await createConvoWithTimestamps(
|
||||||
|
2,
|
||||||
|
new Date('2026-01-02T00:00:00.000Z'), // middle created
|
||||||
|
new Date('2026-01-02T00:00:00.000Z'), // middle updated
|
||||||
|
);
|
||||||
|
|
||||||
|
const convo3 = await createConvoWithTimestamps(
|
||||||
|
3,
|
||||||
|
new Date('2026-01-03T00:00:00.000Z'), // newest created
|
||||||
|
new Date('2026-01-01T00:00:00.000Z'), // oldest updated
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await getConvosByCursor('user123');
|
||||||
|
|
||||||
|
// Should be sorted by updatedAt DESC (most recent first)
|
||||||
|
expect(result.conversations).toHaveLength(3);
|
||||||
|
expect(result.conversations[0].conversationId).toBe(convo1.conversationId); // Jan 3 updatedAt
|
||||||
|
expect(result.conversations[1].conversationId).toBe(convo2.conversationId); // Jan 2 updatedAt
|
||||||
|
expect(result.conversations[2].conversationId).toBe(convo3.conversationId); // Jan 1 updatedAt
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle conversations with same updatedAt (tie-breaker)', async () => {
|
||||||
|
const sameTime = new Date('2026-01-01T12:00:00.000Z');
|
||||||
|
|
||||||
|
// Create 3 conversations with exact same updatedAt
|
||||||
|
const convo1 = await createConvoWithTimestamps(1, sameTime, sameTime);
|
||||||
|
const convo2 = await createConvoWithTimestamps(2, sameTime, sameTime);
|
||||||
|
const convo3 = await createConvoWithTimestamps(3, sameTime, sameTime);
|
||||||
|
|
||||||
|
const result = await getConvosByCursor('user123');
|
||||||
|
|
||||||
|
// All 3 should be returned (no skipping due to same timestamps)
|
||||||
|
expect(result.conversations).toHaveLength(3);
|
||||||
|
|
||||||
|
const returnedIds = result.conversations.map((c) => c.conversationId);
|
||||||
|
expect(returnedIds).toContain(convo1.conversationId);
|
||||||
|
expect(returnedIds).toContain(convo2.conversationId);
|
||||||
|
expect(returnedIds).toContain(convo3.conversationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle cursor pagination with conversations updated during pagination', async () => {
|
||||||
|
// Simulate the scenario where a conversation is updated between page fetches
|
||||||
|
const baseTime = new Date('2026-01-01T00:00:00.000Z');
|
||||||
|
|
||||||
|
// Create 30 conversations
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const updatedAt = new Date(baseTime.getTime() - i * 60000);
|
||||||
|
await createConvoWithTimestamps(i, updatedAt, updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch first page
|
||||||
|
const page1 = await getConvosByCursor('user123', { limit: 25 });
|
||||||
|
expect(page1.conversations).toHaveLength(25);
|
||||||
|
|
||||||
|
// Now update one of the conversations that should be on page 2
|
||||||
|
// to have a newer updatedAt (simulating user activity during pagination)
|
||||||
|
const convosOnPage2 = await Conversation.find({ user: 'user123' })
|
||||||
|
.sort({ updatedAt: -1 })
|
||||||
|
.skip(25)
|
||||||
|
.limit(5);
|
||||||
|
|
||||||
|
if (convosOnPage2.length > 0) {
|
||||||
|
const updatedConvo = convosOnPage2[0];
|
||||||
|
await Conversation.updateOne(
|
||||||
|
{ _id: updatedConvo._id },
|
||||||
|
{ updatedAt: new Date('2026-01-02T00:00:00.000Z') }, // Much newer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch second page with original cursor
|
||||||
|
const page2 = await getConvosByCursor('user123', {
|
||||||
|
limit: 25,
|
||||||
|
cursor: page1.nextCursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The updated conversation might not be in page 2 anymore
|
||||||
|
// (it moved to the front), but we should still get remaining items
|
||||||
|
// without errors and without infinite loops
|
||||||
|
expect(page2.conversations.length).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly decode and use cursor for pagination', async () => {
|
||||||
|
const baseTime = new Date('2026-01-01T00:00:00.000Z');
|
||||||
|
|
||||||
|
// Create 30 conversations
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const updatedAt = new Date(baseTime.getTime() - i * 60000);
|
||||||
|
await createConvoWithTimestamps(i, updatedAt, updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch first page
|
||||||
|
const page1 = await getConvosByCursor('user123', { limit: 25 });
|
||||||
|
|
||||||
|
// Decode the cursor to verify it's based on the last RETURNED item
|
||||||
|
const decodedCursor = JSON.parse(Buffer.from(page1.nextCursor, 'base64').toString());
|
||||||
|
|
||||||
|
// The cursor should match the last item in page1 (item at index 24)
|
||||||
|
const lastReturnedItem = page1.conversations[24];
|
||||||
|
|
||||||
|
expect(new Date(decodedCursor.primary).getTime()).toBe(
|
||||||
|
new Date(lastReturnedItem.updatedAt).getTime(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support sortBy createdAt when explicitly requested', async () => {
|
||||||
|
// Create conversations with different timestamps
|
||||||
|
const convo1 = await createConvoWithTimestamps(
|
||||||
|
1,
|
||||||
|
new Date('2026-01-03T00:00:00.000Z'), // newest created
|
||||||
|
new Date('2026-01-01T00:00:00.000Z'), // oldest updated
|
||||||
|
);
|
||||||
|
|
||||||
|
const convo2 = await createConvoWithTimestamps(
|
||||||
|
2,
|
||||||
|
new Date('2026-01-01T00:00:00.000Z'), // oldest created
|
||||||
|
new Date('2026-01-03T00:00:00.000Z'), // newest updated
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify timestamps were set correctly
|
||||||
|
expect(new Date(convo1.createdAt).getTime()).toBe(
|
||||||
|
new Date('2026-01-03T00:00:00.000Z').getTime(),
|
||||||
|
);
|
||||||
|
expect(new Date(convo2.createdAt).getTime()).toBe(
|
||||||
|
new Date('2026-01-01T00:00:00.000Z').getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await getConvosByCursor('user123', { sortBy: 'createdAt' });
|
||||||
|
|
||||||
|
// Should be sorted by createdAt DESC
|
||||||
|
expect(result.conversations).toHaveLength(2);
|
||||||
|
expect(result.conversations[0].conversationId).toBe(convo1.conversationId); // Jan 3 createdAt
|
||||||
|
expect(result.conversations[1].conversationId).toBe(convo2.conversationId); // Jan 1 createdAt
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty result set gracefully', async () => {
|
||||||
|
const result = await getConvosByCursor('user123');
|
||||||
|
|
||||||
|
expect(result.conversations).toHaveLength(0);
|
||||||
|
expect(result.nextCursor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle exactly limit number of conversations (no next page)', async () => {
|
||||||
|
const baseTime = new Date('2026-01-01T00:00:00.000Z');
|
||||||
|
|
||||||
|
// Create exactly 25 conversations (equal to default limit)
|
||||||
|
for (let i = 0; i < 25; i++) {
|
||||||
|
const updatedAt = new Date(baseTime.getTime() - i * 60000);
|
||||||
|
await createConvoWithTimestamps(i, updatedAt, updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getConvosByCursor('user123', { limit: 25 });
|
||||||
|
|
||||||
|
expect(result.conversations).toHaveLength(25);
|
||||||
|
expect(result.nextCursor).toBeNull(); // No next page
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const { createModels } = require('@librechat/data-schemas');
|
|
||||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
|
const { createModels, createMethods } = require('@librechat/data-schemas');
|
||||||
const {
|
const {
|
||||||
SystemRoles,
|
SystemRoles,
|
||||||
ResourceType,
|
ResourceType,
|
||||||
|
|
@ -9,8 +9,6 @@ const {
|
||||||
PrincipalType,
|
PrincipalType,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { grantPermission } = require('~/server/services/PermissionService');
|
const { grantPermission } = require('~/server/services/PermissionService');
|
||||||
const { getFiles, createFile } = require('./File');
|
|
||||||
const { seedDefaultRoles } = require('~/models');
|
|
||||||
const { createAgent } = require('./Agent');
|
const { createAgent } = require('./Agent');
|
||||||
|
|
||||||
let File;
|
let File;
|
||||||
|
|
@ -18,6 +16,10 @@ let Agent;
|
||||||
let AclEntry;
|
let AclEntry;
|
||||||
let User;
|
let User;
|
||||||
let modelsToCleanup = [];
|
let modelsToCleanup = [];
|
||||||
|
let methods;
|
||||||
|
let getFiles;
|
||||||
|
let createFile;
|
||||||
|
let seedDefaultRoles;
|
||||||
|
|
||||||
describe('File Access Control', () => {
|
describe('File Access Control', () => {
|
||||||
let mongoServer;
|
let mongoServer;
|
||||||
|
|
@ -42,6 +44,12 @@ describe('File Access Control', () => {
|
||||||
AclEntry = dbModels.AclEntry;
|
AclEntry = dbModels.AclEntry;
|
||||||
User = dbModels.User;
|
User = dbModels.User;
|
||||||
|
|
||||||
|
// Create methods from data-schemas (includes file methods)
|
||||||
|
methods = createMethods(mongoose);
|
||||||
|
getFiles = methods.getFiles;
|
||||||
|
createFile = methods.createFile;
|
||||||
|
seedDefaultRoles = methods.seedDefaultRoles;
|
||||||
|
|
||||||
// Seed default roles
|
// Seed default roles
|
||||||
await seedDefaultRoles();
|
await seedDefaultRoles();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -573,4 +573,326 @@ describe('Message Operations', () => {
|
||||||
expect(bulk2.expiredAt).toBeNull();
|
expect(bulk2.expiredAt).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Message cursor pagination', () => {
|
||||||
|
/**
|
||||||
|
* Helper to create messages with specific timestamps
|
||||||
|
* Uses collection.insertOne to bypass Mongoose timestamps
|
||||||
|
*/
|
||||||
|
const createMessageWithTimestamp = async (index, conversationId, createdAt) => {
|
||||||
|
const messageId = uuidv4();
|
||||||
|
await Message.collection.insertOne({
|
||||||
|
messageId,
|
||||||
|
conversationId,
|
||||||
|
user: 'user123',
|
||||||
|
text: `Message ${index}`,
|
||||||
|
isCreatedByUser: index % 2 === 0,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
});
|
||||||
|
return Message.findOne({ messageId }).lean();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulates the pagination logic from api/server/routes/messages.js
|
||||||
|
* This tests the exact query pattern used in the route
|
||||||
|
*/
|
||||||
|
const getMessagesByCursor = async ({
|
||||||
|
conversationId,
|
||||||
|
user,
|
||||||
|
pageSize = 25,
|
||||||
|
cursor = null,
|
||||||
|
sortBy = 'createdAt',
|
||||||
|
sortDirection = 'desc',
|
||||||
|
}) => {
|
||||||
|
const sortOrder = sortDirection === 'asc' ? 1 : -1;
|
||||||
|
const sortField = ['createdAt', 'updatedAt'].includes(sortBy) ? sortBy : 'createdAt';
|
||||||
|
const cursorOperator = sortDirection === 'asc' ? '$gt' : '$lt';
|
||||||
|
|
||||||
|
const filter = { conversationId, user };
|
||||||
|
if (cursor) {
|
||||||
|
filter[sortField] = { [cursorOperator]: new Date(cursor) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await Message.find(filter)
|
||||||
|
.sort({ [sortField]: sortOrder })
|
||||||
|
.limit(pageSize + 1)
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
let nextCursor = null;
|
||||||
|
if (messages.length > pageSize) {
|
||||||
|
messages.pop(); // Remove extra item used to detect next page
|
||||||
|
// Create cursor from the last RETURNED item (not the popped one)
|
||||||
|
nextCursor = messages[messages.length - 1][sortField];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { messages, nextCursor };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should return messages for a conversation with pagination', async () => {
|
||||||
|
const conversationId = uuidv4();
|
||||||
|
const baseTime = new Date('2026-01-01T00:00:00.000Z');
|
||||||
|
|
||||||
|
// Create 30 messages to test pagination
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const createdAt = new Date(baseTime.getTime() - i * 60000); // Each 1 minute apart
|
||||||
|
await createMessageWithTimestamp(i, conversationId, createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch first page (pageSize 25)
|
||||||
|
const page1 = await getMessagesByCursor({
|
||||||
|
conversationId,
|
||||||
|
user: 'user123',
|
||||||
|
pageSize: 25,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(page1.messages).toHaveLength(25);
|
||||||
|
expect(page1.nextCursor).toBeTruthy();
|
||||||
|
|
||||||
|
// Fetch second page using cursor
|
||||||
|
const page2 = await getMessagesByCursor({
|
||||||
|
conversationId,
|
||||||
|
user: 'user123',
|
||||||
|
pageSize: 25,
|
||||||
|
cursor: page1.nextCursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should get remaining 5 messages
|
||||||
|
expect(page2.messages).toHaveLength(5);
|
||||||
|
expect(page2.nextCursor).toBeNull();
|
||||||
|
|
||||||
|
// Verify no duplicates and no gaps
|
||||||
|
const allMessageIds = [
|
||||||
|
...page1.messages.map((m) => m.messageId),
|
||||||
|
...page2.messages.map((m) => m.messageId),
|
||||||
|
];
|
||||||
|
const uniqueIds = new Set(allMessageIds);
|
||||||
|
|
||||||
|
expect(uniqueIds.size).toBe(30); // All 30 messages accounted for
|
||||||
|
expect(allMessageIds.length).toBe(30); // No duplicates
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not skip message at page boundary (item 26 bug fix)', async () => {
|
||||||
|
const conversationId = uuidv4();
|
||||||
|
const baseTime = new Date('2026-01-01T12:00:00.000Z');
|
||||||
|
|
||||||
|
// Create exactly 26 messages
|
||||||
|
const messages = [];
|
||||||
|
for (let i = 0; i < 26; i++) {
|
||||||
|
const createdAt = new Date(baseTime.getTime() - i * 60000);
|
||||||
|
const msg = await createMessageWithTimestamp(i, conversationId, createdAt);
|
||||||
|
messages.push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 26th message (index 25) should be on page 2
|
||||||
|
const item26 = messages[25];
|
||||||
|
|
||||||
|
// Fetch first page with pageSize 25
|
||||||
|
const page1 = await getMessagesByCursor({
|
||||||
|
conversationId,
|
||||||
|
user: 'user123',
|
||||||
|
pageSize: 25,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(page1.messages).toHaveLength(25);
|
||||||
|
expect(page1.nextCursor).toBeTruthy();
|
||||||
|
|
||||||
|
// Item 26 should NOT be in page 1
|
||||||
|
const page1Ids = page1.messages.map((m) => m.messageId);
|
||||||
|
expect(page1Ids).not.toContain(item26.messageId);
|
||||||
|
|
||||||
|
// Fetch second page
|
||||||
|
const page2 = await getMessagesByCursor({
|
||||||
|
conversationId,
|
||||||
|
user: 'user123',
|
||||||
|
pageSize: 25,
|
||||||
|
cursor: page1.nextCursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Item 26 MUST be in page 2 (this was the bug - it was being skipped)
|
||||||
|
expect(page2.messages).toHaveLength(1);
|
||||||
|
expect(page2.messages[0].messageId).toBe(item26.messageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sort by createdAt DESC by default', async () => {
|
||||||
|
const conversationId = uuidv4();
|
||||||
|
|
||||||
|
// Create messages with specific timestamps
|
||||||
|
const msg1 = await createMessageWithTimestamp(
|
||||||
|
1,
|
||||||
|
conversationId,
|
||||||
|
new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
);
|
||||||
|
const msg2 = await createMessageWithTimestamp(
|
||||||
|
2,
|
||||||
|
conversationId,
|
||||||
|
new Date('2026-01-02T00:00:00.000Z'),
|
||||||
|
);
|
||||||
|
const msg3 = await createMessageWithTimestamp(
|
||||||
|
3,
|
||||||
|
conversationId,
|
||||||
|
new Date('2026-01-03T00:00:00.000Z'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await getMessagesByCursor({
|
||||||
|
conversationId,
|
||||||
|
user: 'user123',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should be sorted by createdAt DESC (newest first) by default
|
||||||
|
expect(result.messages).toHaveLength(3);
|
||||||
|
expect(result.messages[0].messageId).toBe(msg3.messageId);
|
||||||
|
expect(result.messages[1].messageId).toBe(msg2.messageId);
|
||||||
|
expect(result.messages[2].messageId).toBe(msg1.messageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support ascending sort direction', async () => {
|
||||||
|
const conversationId = uuidv4();
|
||||||
|
|
||||||
|
const msg1 = await createMessageWithTimestamp(
|
||||||
|
1,
|
||||||
|
conversationId,
|
||||||
|
new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
);
|
||||||
|
const msg2 = await createMessageWithTimestamp(
|
||||||
|
2,
|
||||||
|
conversationId,
|
||||||
|
new Date('2026-01-02T00:00:00.000Z'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await getMessagesByCursor({
|
||||||
|
conversationId,
|
||||||
|
user: 'user123',
|
||||||
|
sortDirection: 'asc',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should be sorted by createdAt ASC (oldest first)
|
||||||
|
expect(result.messages).toHaveLength(2);
|
||||||
|
expect(result.messages[0].messageId).toBe(msg1.messageId);
|
||||||
|
expect(result.messages[1].messageId).toBe(msg2.messageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty conversation', async () => {
|
||||||
|
const conversationId = uuidv4();
|
||||||
|
|
||||||
|
const result = await getMessagesByCursor({
|
||||||
|
conversationId,
|
||||||
|
user: 'user123',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.messages).toHaveLength(0);
|
||||||
|
expect(result.nextCursor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only return messages for the specified user', async () => {
|
||||||
|
const conversationId = uuidv4();
|
||||||
|
const createdAt = new Date();
|
||||||
|
|
||||||
|
// Create a message for user123
|
||||||
|
await Message.collection.insertOne({
|
||||||
|
messageId: uuidv4(),
|
||||||
|
conversationId,
|
||||||
|
user: 'user123',
|
||||||
|
text: 'User message',
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a message for a different user
|
||||||
|
await Message.collection.insertOne({
|
||||||
|
messageId: uuidv4(),
|
||||||
|
conversationId,
|
||||||
|
user: 'otherUser',
|
||||||
|
text: 'Other user message',
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getMessagesByCursor({
|
||||||
|
conversationId,
|
||||||
|
user: 'user123',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should only return user123's message
|
||||||
|
expect(result.messages).toHaveLength(1);
|
||||||
|
expect(result.messages[0].user).toBe('user123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle exactly pageSize number of messages (no next page)', async () => {
|
||||||
|
const conversationId = uuidv4();
|
||||||
|
const baseTime = new Date('2026-01-01T00:00:00.000Z');
|
||||||
|
|
||||||
|
// Create exactly 25 messages (equal to default pageSize)
|
||||||
|
for (let i = 0; i < 25; i++) {
|
||||||
|
const createdAt = new Date(baseTime.getTime() - i * 60000);
|
||||||
|
await createMessageWithTimestamp(i, conversationId, createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getMessagesByCursor({
|
||||||
|
conversationId,
|
||||||
|
user: 'user123',
|
||||||
|
pageSize: 25,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.messages).toHaveLength(25);
|
||||||
|
expect(result.nextCursor).toBeNull(); // No next page
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle pageSize of 1', async () => {
|
||||||
|
const conversationId = uuidv4();
|
||||||
|
const baseTime = new Date('2026-01-01T00:00:00.000Z');
|
||||||
|
|
||||||
|
// Create 3 messages
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const createdAt = new Date(baseTime.getTime() - i * 60000);
|
||||||
|
await createMessageWithTimestamp(i, conversationId, createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch with pageSize 1
|
||||||
|
let cursor = null;
|
||||||
|
const allMessages = [];
|
||||||
|
|
||||||
|
for (let page = 0; page < 5; page++) {
|
||||||
|
const result = await getMessagesByCursor({
|
||||||
|
conversationId,
|
||||||
|
user: 'user123',
|
||||||
|
pageSize: 1,
|
||||||
|
cursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
allMessages.push(...result.messages);
|
||||||
|
cursor = result.nextCursor;
|
||||||
|
|
||||||
|
if (!cursor) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should get all 3 messages without duplicates
|
||||||
|
expect(allMessages).toHaveLength(3);
|
||||||
|
const uniqueIds = new Set(allMessages.map((m) => m.messageId));
|
||||||
|
expect(uniqueIds.size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle messages with same createdAt timestamp', async () => {
|
||||||
|
const conversationId = uuidv4();
|
||||||
|
const sameTime = new Date('2026-01-01T12:00:00.000Z');
|
||||||
|
|
||||||
|
// Create multiple messages with the exact same timestamp
|
||||||
|
const messages = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const msg = await createMessageWithTimestamp(i, conversationId, sameTime);
|
||||||
|
messages.push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getMessagesByCursor({
|
||||||
|
conversationId,
|
||||||
|
user: 'user123',
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// All messages should be returned
|
||||||
|
expect(result.messages).toHaveLength(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const { ObjectId } = require('mongodb');
|
const { ObjectId } = require('mongodb');
|
||||||
|
const { escapeRegExp } = require('@librechat/api');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const {
|
const {
|
||||||
Constants,
|
Constants,
|
||||||
|
|
@ -14,7 +15,6 @@ const {
|
||||||
} = require('./Project');
|
} = require('./Project');
|
||||||
const { removeAllPermissions } = require('~/server/services/PermissionService');
|
const { removeAllPermissions } = require('~/server/services/PermissionService');
|
||||||
const { PromptGroup, Prompt, AclEntry } = require('~/db/models');
|
const { PromptGroup, Prompt, AclEntry } = require('~/db/models');
|
||||||
const { escapeRegExp } = require('~/server/utils');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a pipeline for the aggregation to get prompt groups
|
* Create a pipeline for the aggregation to get prompt groups
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ describe('updateAccessPermissions', () => {
|
||||||
[PermissionTypes.PROMPTS]: {
|
[PermissionTypes.PROMPTS]: {
|
||||||
CREATE: true,
|
CREATE: true,
|
||||||
USE: true,
|
USE: true,
|
||||||
SHARED_GLOBAL: false,
|
SHARE: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}).save();
|
}).save();
|
||||||
|
|
@ -55,7 +55,7 @@ describe('updateAccessPermissions', () => {
|
||||||
[PermissionTypes.PROMPTS]: {
|
[PermissionTypes.PROMPTS]: {
|
||||||
CREATE: true,
|
CREATE: true,
|
||||||
USE: true,
|
USE: true,
|
||||||
SHARED_GLOBAL: true,
|
SHARE: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -63,7 +63,7 @@ describe('updateAccessPermissions', () => {
|
||||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
||||||
CREATE: true,
|
CREATE: true,
|
||||||
USE: true,
|
USE: true,
|
||||||
SHARED_GLOBAL: true,
|
SHARE: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -74,7 +74,7 @@ describe('updateAccessPermissions', () => {
|
||||||
[PermissionTypes.PROMPTS]: {
|
[PermissionTypes.PROMPTS]: {
|
||||||
CREATE: true,
|
CREATE: true,
|
||||||
USE: true,
|
USE: true,
|
||||||
SHARED_GLOBAL: false,
|
SHARE: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}).save();
|
}).save();
|
||||||
|
|
@ -83,7 +83,7 @@ describe('updateAccessPermissions', () => {
|
||||||
[PermissionTypes.PROMPTS]: {
|
[PermissionTypes.PROMPTS]: {
|
||||||
CREATE: true,
|
CREATE: true,
|
||||||
USE: true,
|
USE: true,
|
||||||
SHARED_GLOBAL: false,
|
SHARE: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -91,7 +91,7 @@ describe('updateAccessPermissions', () => {
|
||||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
||||||
CREATE: true,
|
CREATE: true,
|
||||||
USE: true,
|
USE: true,
|
||||||
SHARED_GLOBAL: false,
|
SHARE: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -110,20 +110,20 @@ describe('updateAccessPermissions', () => {
|
||||||
[PermissionTypes.PROMPTS]: {
|
[PermissionTypes.PROMPTS]: {
|
||||||
CREATE: true,
|
CREATE: true,
|
||||||
USE: true,
|
USE: true,
|
||||||
SHARED_GLOBAL: false,
|
SHARE: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}).save();
|
}).save();
|
||||||
|
|
||||||
await updateAccessPermissions(SystemRoles.USER, {
|
await updateAccessPermissions(SystemRoles.USER, {
|
||||||
[PermissionTypes.PROMPTS]: { SHARED_GLOBAL: true },
|
[PermissionTypes.PROMPTS]: { SHARE: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedRole = await getRoleByName(SystemRoles.USER);
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
||||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
||||||
CREATE: true,
|
CREATE: true,
|
||||||
USE: true,
|
USE: true,
|
||||||
SHARED_GLOBAL: true,
|
SHARE: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -134,7 +134,7 @@ describe('updateAccessPermissions', () => {
|
||||||
[PermissionTypes.PROMPTS]: {
|
[PermissionTypes.PROMPTS]: {
|
||||||
CREATE: true,
|
CREATE: true,
|
||||||
USE: true,
|
USE: true,
|
||||||
SHARED_GLOBAL: false,
|
SHARE: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}).save();
|
}).save();
|
||||||
|
|
@ -147,7 +147,7 @@ describe('updateAccessPermissions', () => {
|
||||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
||||||
CREATE: true,
|
CREATE: true,
|
||||||
USE: false,
|
USE: false,
|
||||||
SHARED_GLOBAL: false,
|
SHARE: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -155,13 +155,13 @@ describe('updateAccessPermissions', () => {
|
||||||
await new Role({
|
await new Role({
|
||||||
name: SystemRoles.USER,
|
name: SystemRoles.USER,
|
||||||
permissions: {
|
permissions: {
|
||||||
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARED_GLOBAL: false },
|
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARE: false },
|
||||||
[PermissionTypes.BOOKMARKS]: { USE: true },
|
[PermissionTypes.BOOKMARKS]: { USE: true },
|
||||||
},
|
},
|
||||||
}).save();
|
}).save();
|
||||||
|
|
||||||
await updateAccessPermissions(SystemRoles.USER, {
|
await updateAccessPermissions(SystemRoles.USER, {
|
||||||
[PermissionTypes.PROMPTS]: { USE: false, SHARED_GLOBAL: true },
|
[PermissionTypes.PROMPTS]: { USE: false, SHARE: true },
|
||||||
[PermissionTypes.BOOKMARKS]: { USE: false },
|
[PermissionTypes.BOOKMARKS]: { USE: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -169,7 +169,7 @@ describe('updateAccessPermissions', () => {
|
||||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
||||||
CREATE: true,
|
CREATE: true,
|
||||||
USE: false,
|
USE: false,
|
||||||
SHARED_GLOBAL: true,
|
SHARE: true,
|
||||||
});
|
});
|
||||||
expect(updatedRole.permissions[PermissionTypes.BOOKMARKS]).toEqual({ USE: false });
|
expect(updatedRole.permissions[PermissionTypes.BOOKMARKS]).toEqual({ USE: false });
|
||||||
});
|
});
|
||||||
|
|
@ -178,19 +178,19 @@ describe('updateAccessPermissions', () => {
|
||||||
await new Role({
|
await new Role({
|
||||||
name: SystemRoles.USER,
|
name: SystemRoles.USER,
|
||||||
permissions: {
|
permissions: {
|
||||||
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARED_GLOBAL: false },
|
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARE: false },
|
||||||
},
|
},
|
||||||
}).save();
|
}).save();
|
||||||
|
|
||||||
await updateAccessPermissions(SystemRoles.USER, {
|
await updateAccessPermissions(SystemRoles.USER, {
|
||||||
[PermissionTypes.PROMPTS]: { USE: false, SHARED_GLOBAL: true },
|
[PermissionTypes.PROMPTS]: { USE: false, SHARE: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedRole = await getRoleByName(SystemRoles.USER);
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
||||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
||||||
CREATE: true,
|
CREATE: true,
|
||||||
USE: false,
|
USE: false,
|
||||||
SHARED_GLOBAL: true,
|
SHARE: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -214,13 +214,13 @@ describe('updateAccessPermissions', () => {
|
||||||
await new Role({
|
await new Role({
|
||||||
name: SystemRoles.USER,
|
name: SystemRoles.USER,
|
||||||
permissions: {
|
permissions: {
|
||||||
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARED_GLOBAL: false },
|
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARE: false },
|
||||||
[PermissionTypes.MULTI_CONVO]: { USE: false },
|
[PermissionTypes.MULTI_CONVO]: { USE: false },
|
||||||
},
|
},
|
||||||
}).save();
|
}).save();
|
||||||
|
|
||||||
await updateAccessPermissions(SystemRoles.USER, {
|
await updateAccessPermissions(SystemRoles.USER, {
|
||||||
[PermissionTypes.PROMPTS]: { SHARED_GLOBAL: true },
|
[PermissionTypes.PROMPTS]: { SHARE: true },
|
||||||
[PermissionTypes.MULTI_CONVO]: { USE: true },
|
[PermissionTypes.MULTI_CONVO]: { USE: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -228,7 +228,7 @@ describe('updateAccessPermissions', () => {
|
||||||
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
||||||
CREATE: true,
|
CREATE: true,
|
||||||
USE: true,
|
USE: true,
|
||||||
SHARED_GLOBAL: true,
|
SHARE: true,
|
||||||
});
|
});
|
||||||
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]).toEqual({ USE: true });
|
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]).toEqual({ USE: true });
|
||||||
});
|
});
|
||||||
|
|
@ -271,7 +271,7 @@ describe('initializeRoles', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Example: Check default values for ADMIN role
|
// Example: Check default values for ADMIN role
|
||||||
expect(adminRole.permissions[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBe(true);
|
expect(adminRole.permissions[PermissionTypes.PROMPTS].SHARE).toBe(true);
|
||||||
expect(adminRole.permissions[PermissionTypes.BOOKMARKS].USE).toBe(true);
|
expect(adminRole.permissions[PermissionTypes.BOOKMARKS].USE).toBe(true);
|
||||||
expect(adminRole.permissions[PermissionTypes.AGENTS].CREATE).toBe(true);
|
expect(adminRole.permissions[PermissionTypes.AGENTS].CREATE).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
@ -283,7 +283,7 @@ describe('initializeRoles', () => {
|
||||||
[PermissionTypes.PROMPTS]: {
|
[PermissionTypes.PROMPTS]: {
|
||||||
[Permissions.USE]: false,
|
[Permissions.USE]: false,
|
||||||
[Permissions.CREATE]: true,
|
[Permissions.CREATE]: true,
|
||||||
[Permissions.SHARED_GLOBAL]: true,
|
[Permissions.SHARE]: true,
|
||||||
},
|
},
|
||||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||||
},
|
},
|
||||||
|
|
@ -320,7 +320,7 @@ describe('initializeRoles', () => {
|
||||||
expect(userRole.permissions[PermissionTypes.AGENTS]).toBeDefined();
|
expect(userRole.permissions[PermissionTypes.AGENTS]).toBeDefined();
|
||||||
expect(userRole.permissions[PermissionTypes.AGENTS].CREATE).toBeDefined();
|
expect(userRole.permissions[PermissionTypes.AGENTS].CREATE).toBeDefined();
|
||||||
expect(userRole.permissions[PermissionTypes.AGENTS].USE).toBeDefined();
|
expect(userRole.permissions[PermissionTypes.AGENTS].USE).toBeDefined();
|
||||||
expect(userRole.permissions[PermissionTypes.AGENTS].SHARED_GLOBAL).toBeDefined();
|
expect(userRole.permissions[PermissionTypes.AGENTS].SHARE).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle multiple runs without duplicating or modifying data', async () => {
|
it('should handle multiple runs without duplicating or modifying data', async () => {
|
||||||
|
|
@ -348,7 +348,7 @@ describe('initializeRoles', () => {
|
||||||
[PermissionTypes.PROMPTS]: {
|
[PermissionTypes.PROMPTS]: {
|
||||||
[Permissions.USE]: false,
|
[Permissions.USE]: false,
|
||||||
[Permissions.CREATE]: false,
|
[Permissions.CREATE]: false,
|
||||||
[Permissions.SHARED_GLOBAL]: false,
|
[Permissions.SHARE]: false,
|
||||||
},
|
},
|
||||||
[PermissionTypes.BOOKMARKS]:
|
[PermissionTypes.BOOKMARKS]:
|
||||||
roleDefaults[SystemRoles.ADMIN].permissions[PermissionTypes.BOOKMARKS],
|
roleDefaults[SystemRoles.ADMIN].permissions[PermissionTypes.BOOKMARKS],
|
||||||
|
|
@ -365,7 +365,7 @@ describe('initializeRoles', () => {
|
||||||
expect(adminRole.permissions[PermissionTypes.AGENTS]).toBeDefined();
|
expect(adminRole.permissions[PermissionTypes.AGENTS]).toBeDefined();
|
||||||
expect(adminRole.permissions[PermissionTypes.AGENTS].CREATE).toBeDefined();
|
expect(adminRole.permissions[PermissionTypes.AGENTS].CREATE).toBeDefined();
|
||||||
expect(adminRole.permissions[PermissionTypes.AGENTS].USE).toBeDefined();
|
expect(adminRole.permissions[PermissionTypes.AGENTS].USE).toBeDefined();
|
||||||
expect(adminRole.permissions[PermissionTypes.AGENTS].SHARED_GLOBAL).toBeDefined();
|
expect(adminRole.permissions[PermissionTypes.AGENTS].SHARE).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include MULTI_CONVO permissions when creating default roles', async () => {
|
it('should include MULTI_CONVO permissions when creating default roles', async () => {
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,6 @@ const mongoose = require('mongoose');
|
||||||
const { createMethods } = require('@librechat/data-schemas');
|
const { createMethods } = require('@librechat/data-schemas');
|
||||||
const methods = createMethods(mongoose);
|
const methods = createMethods(mongoose);
|
||||||
const { comparePassword } = require('./userMethods');
|
const { comparePassword } = require('./userMethods');
|
||||||
const {
|
|
||||||
findFileById,
|
|
||||||
createFile,
|
|
||||||
updateFile,
|
|
||||||
deleteFile,
|
|
||||||
deleteFiles,
|
|
||||||
getFiles,
|
|
||||||
updateFileUsage,
|
|
||||||
} = require('./File');
|
|
||||||
const {
|
const {
|
||||||
getMessage,
|
getMessage,
|
||||||
getMessages,
|
getMessages,
|
||||||
|
|
@ -34,13 +25,6 @@ module.exports = {
|
||||||
...methods,
|
...methods,
|
||||||
seedDatabase,
|
seedDatabase,
|
||||||
comparePassword,
|
comparePassword,
|
||||||
findFileById,
|
|
||||||
createFile,
|
|
||||||
updateFile,
|
|
||||||
deleteFile,
|
|
||||||
deleteFiles,
|
|
||||||
getFiles,
|
|
||||||
updateFileUsage,
|
|
||||||
|
|
||||||
getMessage,
|
getMessage,
|
||||||
getMessages,
|
getMessages,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const { getRandomValues } = require('@librechat/api');
|
const { logger, hashToken, getRandomValues } = require('@librechat/data-schemas');
|
||||||
const { logger, hashToken } = require('@librechat/data-schemas');
|
|
||||||
const { createToken, findToken } = require('~/models');
|
const { createToken, findToken } = require('~/models');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
218
api/models/loadAddedAgent.js
Normal file
218
api/models/loadAddedAgent.js
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const { getCustomEndpointConfig } = require('@librechat/api');
|
||||||
|
const {
|
||||||
|
Tools,
|
||||||
|
Constants,
|
||||||
|
isAgentsEndpoint,
|
||||||
|
isEphemeralAgentId,
|
||||||
|
appendAgentIdSuffix,
|
||||||
|
encodeEphemeralAgentId,
|
||||||
|
} = require('librechat-data-provider');
|
||||||
|
const { getMCPServerTools } = require('~/server/services/Config');
|
||||||
|
|
||||||
|
const { mcp_all, mcp_delimiter } = Constants;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constant for added conversation agent ID
|
||||||
|
*/
|
||||||
|
const ADDED_AGENT_ID = 'added_agent';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an agent document based on the provided ID.
|
||||||
|
* @param {Object} searchParameter - The search parameters to find the agent.
|
||||||
|
* @param {string} searchParameter.id - The ID of the agent.
|
||||||
|
* @returns {Promise<import('librechat-data-provider').Agent|null>}
|
||||||
|
*/
|
||||||
|
let getAgent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the getAgent function (dependency injection to avoid circular imports)
|
||||||
|
* @param {Function} fn
|
||||||
|
*/
|
||||||
|
const setGetAgent = (fn) => {
|
||||||
|
getAgent = fn;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an agent from an added conversation (TConversation).
|
||||||
|
* Used for multi-convo parallel agent execution.
|
||||||
|
*
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {import('express').Request} params.req
|
||||||
|
* @param {import('librechat-data-provider').TConversation} params.conversation - The added conversation
|
||||||
|
* @param {import('librechat-data-provider').Agent} [params.primaryAgent] - The primary agent (used to duplicate tools when both are ephemeral)
|
||||||
|
* @returns {Promise<import('librechat-data-provider').Agent|null>} The agent config as a plain object, or null if invalid.
|
||||||
|
*/
|
||||||
|
const loadAddedAgent = async ({ req, conversation, primaryAgent }) => {
|
||||||
|
if (!conversation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's an agent_id, load the existing agent
|
||||||
|
if (conversation.agent_id && !isEphemeralAgentId(conversation.agent_id)) {
|
||||||
|
if (!getAgent) {
|
||||||
|
throw new Error('getAgent not initialized - call setGetAgent first');
|
||||||
|
}
|
||||||
|
const agent = await getAgent({
|
||||||
|
id: conversation.agent_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
logger.warn(`[loadAddedAgent] Agent ${conversation.agent_id} not found`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.version = agent.versions ? agent.versions.length : 0;
|
||||||
|
// Append suffix to distinguish from primary agent (matches ephemeral format)
|
||||||
|
// This is needed when both agents have the same ID or for consistent parallel content attribution
|
||||||
|
agent.id = appendAgentIdSuffix(agent.id, 1);
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, create an ephemeral agent config from the conversation
|
||||||
|
const { model, endpoint, promptPrefix, spec, ...rest } = conversation;
|
||||||
|
|
||||||
|
if (!endpoint || !model) {
|
||||||
|
logger.warn('[loadAddedAgent] Missing required endpoint or model for ephemeral agent');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If both primary and added agents are ephemeral, duplicate tools from primary agent
|
||||||
|
const primaryIsEphemeral = primaryAgent && isEphemeralAgentId(primaryAgent.id);
|
||||||
|
if (primaryIsEphemeral && Array.isArray(primaryAgent.tools)) {
|
||||||
|
// Get endpoint config and model spec for display name fallbacks
|
||||||
|
const appConfig = req.config;
|
||||||
|
let endpointConfig = appConfig?.endpoints?.[endpoint];
|
||||||
|
if (!isAgentsEndpoint(endpoint) && !endpointConfig) {
|
||||||
|
try {
|
||||||
|
endpointConfig = getCustomEndpointConfig({ endpoint, appConfig });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[loadAddedAgent] Error getting custom endpoint config', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up model spec for label fallback
|
||||||
|
const modelSpecs = appConfig?.modelSpecs?.list;
|
||||||
|
const modelSpec = spec != null && spec !== '' ? modelSpecs?.find((s) => s.name === spec) : null;
|
||||||
|
|
||||||
|
// For ephemeral agents, use modelLabel if provided, then model spec's label,
|
||||||
|
// then modelDisplayLabel from endpoint config, otherwise empty string to show model name
|
||||||
|
const sender = rest.modelLabel ?? modelSpec?.label ?? endpointConfig?.modelDisplayLabel ?? '';
|
||||||
|
|
||||||
|
const ephemeralId = encodeEphemeralAgentId({ endpoint, model, sender, index: 1 });
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: ephemeralId,
|
||||||
|
instructions: promptPrefix || '',
|
||||||
|
provider: endpoint,
|
||||||
|
model_parameters: {},
|
||||||
|
model,
|
||||||
|
tools: [...primaryAgent.tools],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract ephemeral agent options from conversation if present
|
||||||
|
const ephemeralAgent = rest.ephemeralAgent;
|
||||||
|
const mcpServers = new Set(ephemeralAgent?.mcp);
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
// Check model spec for MCP servers
|
||||||
|
const modelSpecs = req.config?.modelSpecs?.list;
|
||||||
|
let modelSpec = null;
|
||||||
|
if (spec != null && spec !== '') {
|
||||||
|
modelSpec = modelSpecs?.find((s) => s.name === spec) || null;
|
||||||
|
}
|
||||||
|
if (modelSpec?.mcpServers) {
|
||||||
|
for (const mcpServer of modelSpec.mcpServers) {
|
||||||
|
mcpServers.add(mcpServer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {string[]} */
|
||||||
|
const tools = [];
|
||||||
|
if (ephemeralAgent?.execute_code === true || modelSpec?.executeCode === true) {
|
||||||
|
tools.push(Tools.execute_code);
|
||||||
|
}
|
||||||
|
if (ephemeralAgent?.file_search === true || modelSpec?.fileSearch === true) {
|
||||||
|
tools.push(Tools.file_search);
|
||||||
|
}
|
||||||
|
if (ephemeralAgent?.web_search === true || modelSpec?.webSearch === true) {
|
||||||
|
tools.push(Tools.web_search);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addedServers = new Set();
|
||||||
|
if (mcpServers.size > 0) {
|
||||||
|
for (const mcpServer of mcpServers) {
|
||||||
|
if (addedServers.has(mcpServer)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const serverTools = await getMCPServerTools(userId, mcpServer);
|
||||||
|
if (!serverTools) {
|
||||||
|
tools.push(`${mcp_all}${mcp_delimiter}${mcpServer}`);
|
||||||
|
addedServers.add(mcpServer);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tools.push(...Object.keys(serverTools));
|
||||||
|
addedServers.add(mcpServer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build model_parameters from conversation fields
|
||||||
|
const model_parameters = {};
|
||||||
|
const paramKeys = [
|
||||||
|
'temperature',
|
||||||
|
'top_p',
|
||||||
|
'topP',
|
||||||
|
'topK',
|
||||||
|
'presence_penalty',
|
||||||
|
'frequency_penalty',
|
||||||
|
'maxOutputTokens',
|
||||||
|
'maxTokens',
|
||||||
|
'max_tokens',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const key of paramKeys) {
|
||||||
|
if (rest[key] != null) {
|
||||||
|
model_parameters[key] = rest[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get endpoint config for modelDisplayLabel fallback
|
||||||
|
const appConfig = req.config;
|
||||||
|
let endpointConfig = appConfig?.endpoints?.[endpoint];
|
||||||
|
if (!isAgentsEndpoint(endpoint) && !endpointConfig) {
|
||||||
|
try {
|
||||||
|
endpointConfig = getCustomEndpointConfig({ endpoint, appConfig });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[loadAddedAgent] Error getting custom endpoint config', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For ephemeral agents, use modelLabel if provided, then model spec's label,
|
||||||
|
// then modelDisplayLabel from endpoint config, otherwise empty string to show model name
|
||||||
|
const sender = rest.modelLabel ?? modelSpec?.label ?? endpointConfig?.modelDisplayLabel ?? '';
|
||||||
|
|
||||||
|
/** Encoded ephemeral agent ID with endpoint, model, sender, and index=1 to distinguish from primary */
|
||||||
|
const ephemeralId = encodeEphemeralAgentId({ endpoint, model, sender, index: 1 });
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
id: ephemeralId,
|
||||||
|
instructions: promptPrefix || '',
|
||||||
|
provider: endpoint,
|
||||||
|
model_parameters,
|
||||||
|
model,
|
||||||
|
tools,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ephemeralAgent?.artifacts != null && ephemeralAgent.artifacts) {
|
||||||
|
result.artifacts = ephemeralAgent.artifacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ADDED_AGENT_ID,
|
||||||
|
loadAddedAgent,
|
||||||
|
setGetAgent,
|
||||||
|
};
|
||||||
|
|
@ -113,6 +113,8 @@ const tokenValues = Object.assign(
|
||||||
'gpt-4o-2024-05-13': { prompt: 5, completion: 15 },
|
'gpt-4o-2024-05-13': { prompt: 5, completion: 15 },
|
||||||
'gpt-4o-mini': { prompt: 0.15, completion: 0.6 },
|
'gpt-4o-mini': { prompt: 0.15, completion: 0.6 },
|
||||||
'gpt-5': { prompt: 1.25, completion: 10 },
|
'gpt-5': { prompt: 1.25, completion: 10 },
|
||||||
|
'gpt-5.1': { prompt: 1.25, completion: 10 },
|
||||||
|
'gpt-5.2': { prompt: 1.75, completion: 14 },
|
||||||
'gpt-5-nano': { prompt: 0.05, completion: 0.4 },
|
'gpt-5-nano': { prompt: 0.05, completion: 0.4 },
|
||||||
'gpt-5-mini': { prompt: 0.25, completion: 2 },
|
'gpt-5-mini': { prompt: 0.25, completion: 2 },
|
||||||
'gpt-5-pro': { prompt: 15, completion: 120 },
|
'gpt-5-pro': { prompt: 15, completion: 120 },
|
||||||
|
|
@ -141,6 +143,7 @@ const tokenValues = Object.assign(
|
||||||
'command-r': { prompt: 0.5, completion: 1.5 },
|
'command-r': { prompt: 0.5, completion: 1.5 },
|
||||||
'command-r-plus': { prompt: 3, completion: 15 },
|
'command-r-plus': { prompt: 3, completion: 15 },
|
||||||
'command-text': { prompt: 1.5, completion: 2.0 },
|
'command-text': { prompt: 1.5, completion: 2.0 },
|
||||||
|
'deepseek-chat': { prompt: 0.28, completion: 0.42 },
|
||||||
'deepseek-reasoner': { prompt: 0.28, completion: 0.42 },
|
'deepseek-reasoner': { prompt: 0.28, completion: 0.42 },
|
||||||
'deepseek-r1': { prompt: 0.4, completion: 2.0 },
|
'deepseek-r1': { prompt: 0.4, completion: 2.0 },
|
||||||
'deepseek-v3': { prompt: 0.2, completion: 0.8 },
|
'deepseek-v3': { prompt: 0.2, completion: 0.8 },
|
||||||
|
|
@ -157,7 +160,9 @@ const tokenValues = Object.assign(
|
||||||
'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 },
|
'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 },
|
||||||
'gemini-2.5-flash-lite': { prompt: 0.1, completion: 0.4 },
|
'gemini-2.5-flash-lite': { prompt: 0.1, completion: 0.4 },
|
||||||
'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
|
'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
|
||||||
|
'gemini-2.5-flash-image': { prompt: 0.15, completion: 30 },
|
||||||
'gemini-3': { prompt: 2, completion: 12 },
|
'gemini-3': { prompt: 2, completion: 12 },
|
||||||
|
'gemini-3-pro-image': { prompt: 2, completion: 120 },
|
||||||
'gemini-pro-vision': { prompt: 0.5, completion: 1.5 },
|
'gemini-pro-vision': { prompt: 0.5, completion: 1.5 },
|
||||||
grok: { prompt: 2.0, completion: 10.0 }, // Base pattern defaults to grok-2
|
grok: { prompt: 2.0, completion: 10.0 }, // Base pattern defaults to grok-2
|
||||||
'grok-beta': { prompt: 5.0, completion: 15.0 },
|
'grok-beta': { prompt: 5.0, completion: 15.0 },
|
||||||
|
|
@ -173,6 +178,9 @@ const tokenValues = Object.assign(
|
||||||
'grok-3-mini': { prompt: 0.3, completion: 0.5 },
|
'grok-3-mini': { prompt: 0.3, completion: 0.5 },
|
||||||
'grok-3-mini-fast': { prompt: 0.6, completion: 4 },
|
'grok-3-mini-fast': { prompt: 0.6, completion: 4 },
|
||||||
'grok-4': { prompt: 3.0, completion: 15.0 },
|
'grok-4': { prompt: 3.0, completion: 15.0 },
|
||||||
|
'grok-4-fast': { prompt: 0.2, completion: 0.5 },
|
||||||
|
'grok-4-1-fast': { prompt: 0.2, completion: 0.5 }, // covers reasoning & non-reasoning variants
|
||||||
|
'grok-code-fast': { prompt: 0.2, completion: 1.5 },
|
||||||
codestral: { prompt: 0.3, completion: 0.9 },
|
codestral: { prompt: 0.3, completion: 0.9 },
|
||||||
'ministral-3b': { prompt: 0.04, completion: 0.04 },
|
'ministral-3b': { prompt: 0.04, completion: 0.04 },
|
||||||
'ministral-8b': { prompt: 0.1, completion: 0.1 },
|
'ministral-8b': { prompt: 0.1, completion: 0.1 },
|
||||||
|
|
@ -243,6 +251,10 @@ const cacheTokenValues = {
|
||||||
'claude-sonnet-4': { write: 3.75, read: 0.3 },
|
'claude-sonnet-4': { write: 3.75, read: 0.3 },
|
||||||
'claude-opus-4': { write: 18.75, read: 1.5 },
|
'claude-opus-4': { write: 18.75, read: 1.5 },
|
||||||
'claude-opus-4-5': { write: 6.25, read: 0.5 },
|
'claude-opus-4-5': { write: 6.25, read: 0.5 },
|
||||||
|
// DeepSeek models - cache hit: $0.028/1M, cache miss: $0.28/1M
|
||||||
|
deepseek: { write: 0.28, read: 0.028 },
|
||||||
|
'deepseek-chat': { write: 0.28, read: 0.028 },
|
||||||
|
'deepseek-reasoner': { write: 0.28, read: 0.028 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,19 @@ describe('getValueKey', () => {
|
||||||
expect(getValueKey('gpt-5-0130')).toBe('gpt-5');
|
expect(getValueKey('gpt-5-0130')).toBe('gpt-5');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return "gpt-5.1" for model name containing "gpt-5.1"', () => {
|
||||||
|
expect(getValueKey('gpt-5.1')).toBe('gpt-5.1');
|
||||||
|
expect(getValueKey('gpt-5.1-chat')).toBe('gpt-5.1');
|
||||||
|
expect(getValueKey('gpt-5.1-codex')).toBe('gpt-5.1');
|
||||||
|
expect(getValueKey('openai/gpt-5.1')).toBe('gpt-5.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "gpt-5.2" for model name containing "gpt-5.2"', () => {
|
||||||
|
expect(getValueKey('gpt-5.2')).toBe('gpt-5.2');
|
||||||
|
expect(getValueKey('gpt-5.2-chat')).toBe('gpt-5.2');
|
||||||
|
expect(getValueKey('openai/gpt-5.2')).toBe('gpt-5.2');
|
||||||
|
});
|
||||||
|
|
||||||
it('should return "gpt-3.5-turbo-1106" for model name containing "gpt-3.5-turbo-1106"', () => {
|
it('should return "gpt-3.5-turbo-1106" for model name containing "gpt-3.5-turbo-1106"', () => {
|
||||||
expect(getValueKey('gpt-3.5-turbo-1106-some-other-info')).toBe('gpt-3.5-turbo-1106');
|
expect(getValueKey('gpt-3.5-turbo-1106-some-other-info')).toBe('gpt-3.5-turbo-1106');
|
||||||
expect(getValueKey('openai/gpt-3.5-turbo-1106')).toBe('gpt-3.5-turbo-1106');
|
expect(getValueKey('openai/gpt-3.5-turbo-1106')).toBe('gpt-3.5-turbo-1106');
|
||||||
|
|
@ -311,6 +324,34 @@ describe('getMultiplier', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return the correct multiplier for gpt-5.1', () => {
|
||||||
|
expect(getMultiplier({ model: 'gpt-5.1', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['gpt-5.1'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'gpt-5.1', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['gpt-5.1'].completion,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'openai/gpt-5.1', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['gpt-5.1'].prompt,
|
||||||
|
);
|
||||||
|
expect(tokenValues['gpt-5.1'].prompt).toBe(1.25);
|
||||||
|
expect(tokenValues['gpt-5.1'].completion).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the correct multiplier for gpt-5.2', () => {
|
||||||
|
expect(getMultiplier({ model: 'gpt-5.2', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['gpt-5.2'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'gpt-5.2', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['gpt-5.2'].completion,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'openai/gpt-5.2', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['gpt-5.2'].prompt,
|
||||||
|
);
|
||||||
|
expect(tokenValues['gpt-5.2'].prompt).toBe(1.75);
|
||||||
|
expect(tokenValues['gpt-5.2'].completion).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
it('should return the correct multiplier for gpt-4o', () => {
|
it('should return the correct multiplier for gpt-4o', () => {
|
||||||
const valueKey = getValueKey('gpt-4o-2024-08-06');
|
const valueKey = getValueKey('gpt-4o-2024-08-06');
|
||||||
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt);
|
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt);
|
||||||
|
|
@ -766,6 +807,78 @@ describe('Deepseek Model Tests', () => {
|
||||||
const result = tokenValues[valueKey].prompt && multiplier === tokenValues[valueKey].prompt;
|
const result = tokenValues[valueKey].prompt && multiplier === tokenValues[valueKey].prompt;
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return correct pricing for deepseek-chat', () => {
|
||||||
|
expect(getMultiplier({ model: 'deepseek-chat', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['deepseek-chat'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'deepseek-chat', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['deepseek-chat'].completion,
|
||||||
|
);
|
||||||
|
expect(tokenValues['deepseek-chat'].prompt).toBe(0.28);
|
||||||
|
expect(tokenValues['deepseek-chat'].completion).toBe(0.42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct pricing for deepseek-reasoner', () => {
|
||||||
|
expect(getMultiplier({ model: 'deepseek-reasoner', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['deepseek-reasoner'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'deepseek-reasoner', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['deepseek-reasoner'].completion,
|
||||||
|
);
|
||||||
|
expect(tokenValues['deepseek-reasoner'].prompt).toBe(0.28);
|
||||||
|
expect(tokenValues['deepseek-reasoner'].completion).toBe(0.42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle DeepSeek model name variations with provider prefixes', () => {
|
||||||
|
const modelVariations = [
|
||||||
|
'deepseek/deepseek-chat',
|
||||||
|
'openrouter/deepseek-chat',
|
||||||
|
'deepseek/deepseek-reasoner',
|
||||||
|
];
|
||||||
|
|
||||||
|
modelVariations.forEach((model) => {
|
||||||
|
const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
|
||||||
|
const completionMultiplier = getMultiplier({ model, tokenType: 'completion' });
|
||||||
|
expect(promptMultiplier).toBe(0.28);
|
||||||
|
expect(completionMultiplier).toBe(0.42);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct cache multipliers for DeepSeek models', () => {
|
||||||
|
expect(getCacheMultiplier({ model: 'deepseek-chat', cacheType: 'write' })).toBe(
|
||||||
|
cacheTokenValues['deepseek-chat'].write,
|
||||||
|
);
|
||||||
|
expect(getCacheMultiplier({ model: 'deepseek-chat', cacheType: 'read' })).toBe(
|
||||||
|
cacheTokenValues['deepseek-chat'].read,
|
||||||
|
);
|
||||||
|
expect(getCacheMultiplier({ model: 'deepseek-reasoner', cacheType: 'write' })).toBe(
|
||||||
|
cacheTokenValues['deepseek-reasoner'].write,
|
||||||
|
);
|
||||||
|
expect(getCacheMultiplier({ model: 'deepseek-reasoner', cacheType: 'read' })).toBe(
|
||||||
|
cacheTokenValues['deepseek-reasoner'].read,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct cache pricing values for DeepSeek models', () => {
|
||||||
|
expect(cacheTokenValues['deepseek-chat'].write).toBe(0.28);
|
||||||
|
expect(cacheTokenValues['deepseek-chat'].read).toBe(0.028);
|
||||||
|
expect(cacheTokenValues['deepseek-reasoner'].write).toBe(0.28);
|
||||||
|
expect(cacheTokenValues['deepseek-reasoner'].read).toBe(0.028);
|
||||||
|
expect(cacheTokenValues['deepseek'].write).toBe(0.28);
|
||||||
|
expect(cacheTokenValues['deepseek'].read).toBe(0.028);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle DeepSeek cache multipliers with model variations', () => {
|
||||||
|
const modelVariations = ['deepseek/deepseek-chat', 'openrouter/deepseek-reasoner'];
|
||||||
|
|
||||||
|
modelVariations.forEach((model) => {
|
||||||
|
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' });
|
||||||
|
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' });
|
||||||
|
expect(writeMultiplier).toBe(0.28);
|
||||||
|
expect(readMultiplier).toBe(0.028);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Qwen3 Model Tests', () => {
|
describe('Qwen3 Model Tests', () => {
|
||||||
|
|
@ -1205,6 +1318,39 @@ describe('Grok Model Tests - Pricing', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should return correct prompt and completion rates for Grok 4 Fast model', () => {
|
||||||
|
expect(getMultiplier({ model: 'grok-4-fast', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-4-fast'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'grok-4-fast', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-4-fast'].completion,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return correct prompt and completion rates for Grok 4.1 Fast models', () => {
|
||||||
|
expect(getMultiplier({ model: 'grok-4-1-fast-reasoning', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-4-1-fast'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'grok-4-1-fast-reasoning', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-4-1-fast'].completion,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'grok-4-1-fast-non-reasoning', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-4-1-fast'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'grok-4-1-fast-non-reasoning', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-4-1-fast'].completion,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return correct prompt and completion rates for Grok Code Fast model', () => {
|
||||||
|
expect(getMultiplier({ model: 'grok-code-fast-1', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-code-fast'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'grok-code-fast-1', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-code-fast'].completion,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('should return correct prompt and completion rates for Grok 3 models with prefixes', () => {
|
test('should return correct prompt and completion rates for Grok 3 models with prefixes', () => {
|
||||||
expect(getMultiplier({ model: 'xai/grok-3', tokenType: 'prompt' })).toBe(
|
expect(getMultiplier({ model: 'xai/grok-3', tokenType: 'prompt' })).toBe(
|
||||||
tokenValues['grok-3'].prompt,
|
tokenValues['grok-3'].prompt,
|
||||||
|
|
@ -1240,6 +1386,39 @@ describe('Grok Model Tests - Pricing', () => {
|
||||||
tokenValues['grok-4'].completion,
|
tokenValues['grok-4'].completion,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should return correct prompt and completion rates for Grok 4 Fast model with prefixes', () => {
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-4-fast', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-4-fast'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-4-fast', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-4-fast'].completion,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return correct prompt and completion rates for Grok 4.1 Fast models with prefixes', () => {
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-4-1-fast-reasoning', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-4-1-fast'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-4-1-fast-reasoning', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-4-1-fast'].completion,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-4-1-fast-non-reasoning', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-4-1-fast'].prompt,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
getMultiplier({ model: 'xai/grok-4-1-fast-non-reasoning', tokenType: 'completion' }),
|
||||||
|
).toBe(tokenValues['grok-4-1-fast'].completion);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return correct prompt and completion rates for Grok Code Fast model with prefixes', () => {
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-code-fast-1', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['grok-code-fast'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'xai/grok-code-fast-1', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['grok-code-fast'].completion,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "@librechat/backend",
|
"name": "@librechat/backend",
|
||||||
"version": "v0.8.1-rc2",
|
"version": "v0.8.2-rc3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "echo 'please run this from the root directory'",
|
"start": "echo 'please run this from the root directory'",
|
||||||
"server-dev": "echo 'please run this from the root directory'",
|
"server-dev": "echo 'please run this from the root directory'",
|
||||||
"test": "cross-env NODE_ENV=test jest",
|
"test": "cross-env NODE_ENV=test jest",
|
||||||
"b:test": "NODE_ENV=test bun jest",
|
"b:test": "NODE_ENV=test bun jest",
|
||||||
"test:ci": "jest --ci",
|
"test:ci": "jest --ci --logHeapUsage",
|
||||||
"add-balance": "node ./add-balance.js",
|
"add-balance": "node ./add-balance.js",
|
||||||
"list-balances": "node ./list-balances.js",
|
"list-balances": "node ./list-balances.js",
|
||||||
"user-stats": "node ./user-stats.js",
|
"user-stats": "node ./user-stats.js",
|
||||||
|
|
@ -34,26 +34,24 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://librechat.ai",
|
"homepage": "https://librechat.ai",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.52.0",
|
"@anthropic-ai/sdk": "^0.71.0",
|
||||||
|
"@anthropic-ai/vertex-sdk": "^0.14.0",
|
||||||
|
"@aws-sdk/client-bedrock-runtime": "^3.941.0",
|
||||||
"@aws-sdk/client-s3": "^3.758.0",
|
"@aws-sdk/client-s3": "^3.758.0",
|
||||||
"@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.27.0",
|
"@azure/storage-blob": "^12.27.0",
|
||||||
"@google/generative-ai": "^0.24.0",
|
"@google/genai": "^1.19.0",
|
||||||
"@googleapis/youtube": "^20.0.0",
|
|
||||||
"@keyv/redis": "^4.3.3",
|
"@keyv/redis": "^4.3.3",
|
||||||
"@langchain/core": "^0.3.79",
|
"@langchain/core": "^0.3.80",
|
||||||
"@langchain/google-genai": "^0.2.13",
|
"@librechat/agents": "^3.0.776",
|
||||||
"@langchain/google-vertexai": "^0.2.13",
|
|
||||||
"@langchain/textsplitters": "^0.1.0",
|
|
||||||
"@librechat/agents": "^3.0.32",
|
|
||||||
"@librechat/api": "*",
|
"@librechat/api": "*",
|
||||||
"@librechat/data-schemas": "*",
|
"@librechat/data-schemas": "*",
|
||||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||||
"@modelcontextprotocol/sdk": "^1.21.0",
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||||
"@node-saml/passport-saml": "^5.1.0",
|
"@node-saml/passport-saml": "^5.1.0",
|
||||||
"@waylaidwanderer/fetch-event-source": "^3.0.1",
|
"@smithy/node-http-handler": "^4.4.5",
|
||||||
"axios": "^1.12.1",
|
"axios": "^1.12.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
|
|
@ -64,15 +62,14 @@
|
||||||
"dedent": "^1.5.3",
|
"dedent": "^1.5.3",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"eventsource": "^3.0.2",
|
"eventsource": "^3.0.2",
|
||||||
"express": "^4.21.2",
|
"express": "^5.2.1",
|
||||||
"express-mongo-sanitize": "^2.2.0",
|
"express-mongo-sanitize": "^2.2.0",
|
||||||
"express-rate-limit": "^7.4.1",
|
"express-rate-limit": "^8.2.1",
|
||||||
"express-session": "^1.18.2",
|
"express-session": "^1.18.2",
|
||||||
"express-static-gzip": "^2.2.0",
|
"express-static-gzip": "^2.2.0",
|
||||||
"file-type": "^18.7.0",
|
"file-type": "^18.7.0",
|
||||||
"firebase": "^11.0.2",
|
"firebase": "^11.0.2",
|
||||||
"form-data": "^4.0.4",
|
"form-data": "^4.0.4",
|
||||||
"googleapis": "^126.0.1",
|
|
||||||
"handlebars": "^4.7.7",
|
"handlebars": "^4.7.7",
|
||||||
"https-proxy-agent": "^7.0.6",
|
"https-proxy-agent": "^7.0.6",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
|
|
@ -83,7 +80,8 @@
|
||||||
"keyv-file": "^5.1.2",
|
"keyv-file": "^5.1.2",
|
||||||
"klona": "^2.0.6",
|
"klona": "^2.0.6",
|
||||||
"librechat-data-provider": "*",
|
"librechat-data-provider": "*",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.23",
|
||||||
|
"mathjs": "^15.1.0",
|
||||||
"meilisearch": "^0.38.0",
|
"meilisearch": "^0.38.0",
|
||||||
"memorystore": "^1.6.7",
|
"memorystore": "^1.6.7",
|
||||||
"mime": "^3.0.0",
|
"mime": "^3.0.0",
|
||||||
|
|
@ -92,7 +90,7 @@
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"nanoid": "^3.3.7",
|
"nanoid": "^3.3.7",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
"nodemailer": "^7.0.9",
|
"nodemailer": "^7.0.11",
|
||||||
"ollama": "^0.5.0",
|
"ollama": "^0.5.0",
|
||||||
"openai": "5.8.2",
|
"openai": "5.8.2",
|
||||||
"openid-client": "^6.5.0",
|
"openid-client": "^6.5.0",
|
||||||
|
|
@ -110,10 +108,9 @@
|
||||||
"tiktoken": "^1.0.15",
|
"tiktoken": "^1.0.15",
|
||||||
"traverse": "^0.6.7",
|
"traverse": "^0.6.7",
|
||||||
"ua-parser-js": "^1.0.36",
|
"ua-parser-js": "^1.0.36",
|
||||||
"undici": "^7.10.0",
|
"undici": "^7.18.2",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
"winston-daily-rotate-file": "^5.0.0",
|
"winston-daily-rotate-file": "^5.0.0",
|
||||||
"youtube-transcript": "^1.2.1",
|
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -350,9 +350,6 @@ function disposeClient(client) {
|
||||||
if (client.agentConfigs) {
|
if (client.agentConfigs) {
|
||||||
client.agentConfigs = null;
|
client.agentConfigs = null;
|
||||||
}
|
}
|
||||||
if (client.agentIdMap) {
|
|
||||||
client.agentIdMap = null;
|
|
||||||
}
|
|
||||||
if (client.artifactPromises) {
|
if (client.artifactPromises) {
|
||||||
client.artifactPromises = null;
|
client.artifactPromises = null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,13 @@ const {
|
||||||
setAuthTokens,
|
setAuthTokens,
|
||||||
registerUser,
|
registerUser,
|
||||||
} = require('~/server/services/AuthService');
|
} = require('~/server/services/AuthService');
|
||||||
const { findUser, getUserById, deleteAllUserSessions, findSession } = require('~/models');
|
const {
|
||||||
|
deleteAllUserSessions,
|
||||||
|
getUserById,
|
||||||
|
findSession,
|
||||||
|
updateUser,
|
||||||
|
findUser,
|
||||||
|
} = require('~/models');
|
||||||
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
|
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
|
||||||
const { getOAuthReconnectionManager } = require('~/config');
|
const { getOAuthReconnectionManager } = require('~/config');
|
||||||
const { getOpenIdConfig } = require('~/strategies');
|
const { getOpenIdConfig } = require('~/strategies');
|
||||||
|
|
@ -60,29 +66,54 @@ const resetPasswordController = async (req, res) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshController = async (req, res) => {
|
const refreshController = async (req, res) => {
|
||||||
const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null;
|
const parsedCookies = req.headers.cookie ? cookies.parse(req.headers.cookie) : {};
|
||||||
const token_provider = req.headers.cookie
|
const token_provider = parsedCookies.token_provider;
|
||||||
? cookies.parse(req.headers.cookie).token_provider
|
|
||||||
: null;
|
if (token_provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
|
||||||
if (!refreshToken) {
|
/** For OpenID users, read refresh token from session to avoid large cookie issues */
|
||||||
return res.status(200).send('Refresh token not provided');
|
const refreshToken = req.session?.openidTokens?.refreshToken || parsedCookies.refreshToken;
|
||||||
}
|
|
||||||
if (token_provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS) === true) {
|
if (!refreshToken) {
|
||||||
|
return res.status(200).send('Refresh token not provided');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const openIdConfig = getOpenIdConfig();
|
const openIdConfig = getOpenIdConfig();
|
||||||
const tokenset = await openIdClient.refreshTokenGrant(openIdConfig, refreshToken);
|
const tokenset = await openIdClient.refreshTokenGrant(openIdConfig, refreshToken);
|
||||||
const claims = tokenset.claims();
|
const claims = tokenset.claims();
|
||||||
const { user, error } = await findOpenIDUser({
|
const { user, error, migration } = await findOpenIDUser({
|
||||||
findUser,
|
findUser,
|
||||||
email: claims.email,
|
email: claims.email,
|
||||||
openidId: claims.sub,
|
openidId: claims.sub,
|
||||||
idOnTheSource: claims.oid,
|
idOnTheSource: claims.oid,
|
||||||
strategyName: 'refreshController',
|
strategyName: 'refreshController',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`[refreshController] findOpenIDUser result: user=${user?.email ?? 'null'}, error=${error ?? 'null'}, migration=${migration}, userOpenidId=${user?.openidId ?? 'null'}, claimsSub=${claims.sub}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (error || !user) {
|
if (error || !user) {
|
||||||
|
logger.warn(
|
||||||
|
`[refreshController] Redirecting to /login: error=${error ?? 'null'}, user=${user ? 'exists' : 'null'}`,
|
||||||
|
);
|
||||||
return res.status(401).redirect('/login');
|
return res.status(401).redirect('/login');
|
||||||
}
|
}
|
||||||
const token = setOpenIDAuthTokens(tokenset, res, user._id.toString(), refreshToken);
|
|
||||||
|
// Handle migration: update user with openidId if found by email without openidId
|
||||||
|
// Also handle case where user has mismatched openidId (e.g., after database switch)
|
||||||
|
if (migration || user.openidId !== claims.sub) {
|
||||||
|
const reason = migration ? 'migration' : 'openidId mismatch';
|
||||||
|
await updateUser(user._id.toString(), {
|
||||||
|
provider: 'openid',
|
||||||
|
openidId: claims.sub,
|
||||||
|
});
|
||||||
|
logger.info(
|
||||||
|
`[refreshController] Updated user ${user.email} openidId (${reason}): ${user.openidId ?? 'null'} -> ${claims.sub}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = setOpenIDAuthTokens(tokenset, req, res, user._id.toString(), refreshToken);
|
||||||
|
|
||||||
user.federatedTokens = {
|
user.federatedTokens = {
|
||||||
access_token: tokenset.access_token,
|
access_token: tokenset.access_token,
|
||||||
|
|
@ -97,6 +128,13 @@ const refreshController = async (req, res) => {
|
||||||
return res.status(403).send('Invalid OpenID refresh token');
|
return res.status(403).send('Invalid OpenID refresh token');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** For non-OpenID users, read refresh token from cookies */
|
||||||
|
const refreshToken = parsedCookies.refreshToken;
|
||||||
|
if (!refreshToken) {
|
||||||
|
return res.status(200).send('Refresh token not provided');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
|
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
|
||||||
const user = await getUserById(payload.id, '-password -__v -totpSecret -backupCodes');
|
const user = await getUserById(payload.id, '-password -__v -totpSecret -backupCodes');
|
||||||
|
|
|
||||||
|
|
@ -1,247 +0,0 @@
|
||||||
const { sendEvent } = require('@librechat/api');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
|
||||||
const { getResponseSender } = require('librechat-data-provider');
|
|
||||||
const {
|
|
||||||
handleAbortError,
|
|
||||||
createAbortController,
|
|
||||||
cleanupAbortController,
|
|
||||||
} = require('~/server/middleware');
|
|
||||||
const {
|
|
||||||
disposeClient,
|
|
||||||
processReqData,
|
|
||||||
clientRegistry,
|
|
||||||
requestDataMap,
|
|
||||||
} = require('~/server/cleanup');
|
|
||||||
const { createOnProgress } = require('~/server/utils');
|
|
||||||
const { saveMessage } = require('~/models');
|
|
||||||
|
|
||||||
const EditController = async (req, res, next, initializeClient) => {
|
|
||||||
let {
|
|
||||||
text,
|
|
||||||
generation,
|
|
||||||
endpointOption,
|
|
||||||
conversationId,
|
|
||||||
modelDisplayLabel,
|
|
||||||
responseMessageId,
|
|
||||||
isContinued = false,
|
|
||||||
parentMessageId = null,
|
|
||||||
overrideParentMessageId = null,
|
|
||||||
} = req.body;
|
|
||||||
|
|
||||||
let client = null;
|
|
||||||
let abortKey = null;
|
|
||||||
let cleanupHandlers = [];
|
|
||||||
let clientRef = null; // Declare clientRef here
|
|
||||||
|
|
||||||
logger.debug('[EditController]', {
|
|
||||||
text,
|
|
||||||
generation,
|
|
||||||
isContinued,
|
|
||||||
conversationId,
|
|
||||||
...endpointOption,
|
|
||||||
modelsConfig: endpointOption.modelsConfig ? 'exists' : '',
|
|
||||||
});
|
|
||||||
|
|
||||||
let userMessage = null;
|
|
||||||
let userMessagePromise = null;
|
|
||||||
let promptTokens = null;
|
|
||||||
let getAbortData = null;
|
|
||||||
|
|
||||||
const sender = getResponseSender({
|
|
||||||
...endpointOption,
|
|
||||||
model: endpointOption.modelOptions.model,
|
|
||||||
modelDisplayLabel,
|
|
||||||
});
|
|
||||||
const userMessageId = parentMessageId;
|
|
||||||
const userId = req.user.id;
|
|
||||||
|
|
||||||
let reqDataContext = { userMessage, userMessagePromise, responseMessageId, promptTokens };
|
|
||||||
|
|
||||||
const updateReqData = (data = {}) => {
|
|
||||||
reqDataContext = processReqData(data, reqDataContext);
|
|
||||||
abortKey = reqDataContext.abortKey;
|
|
||||||
userMessage = reqDataContext.userMessage;
|
|
||||||
userMessagePromise = reqDataContext.userMessagePromise;
|
|
||||||
responseMessageId = reqDataContext.responseMessageId;
|
|
||||||
promptTokens = reqDataContext.promptTokens;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { onProgress: progressCallback, getPartialText } = createOnProgress({
|
|
||||||
generation,
|
|
||||||
});
|
|
||||||
|
|
||||||
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('[EditController] 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 {
|
|
||||||
({ client } = await initializeClient({ req, res, endpointOption }));
|
|
||||||
|
|
||||||
if (clientRegistry && client) {
|
|
||||||
clientRegistry.register(client, { userId }, client);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (client) {
|
|
||||||
requestDataMap.set(req, { client });
|
|
||||||
}
|
|
||||||
|
|
||||||
clientRef = new WeakRef(client);
|
|
||||||
|
|
||||||
getAbortData = () => {
|
|
||||||
const currentClient = clientRef?.deref();
|
|
||||||
const currentText =
|
|
||||||
currentClient?.getStreamText != null ? currentClient.getStreamText() : getPartialText();
|
|
||||||
|
|
||||||
return {
|
|
||||||
sender,
|
|
||||||
conversationId,
|
|
||||||
messageId: reqDataContext.responseMessageId,
|
|
||||||
parentMessageId: overrideParentMessageId ?? userMessageId,
|
|
||||||
text: currentText,
|
|
||||||
userMessage: userMessage,
|
|
||||||
userMessagePromise: userMessagePromise,
|
|
||||||
promptTokens: reqDataContext.promptTokens,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const { onStart, abortController } = createAbortController(
|
|
||||||
req,
|
|
||||||
res,
|
|
||||||
getAbortData,
|
|
||||||
updateReqData,
|
|
||||||
);
|
|
||||||
|
|
||||||
const closeHandler = () => {
|
|
||||||
logger.debug('[EditController] Request closed');
|
|
||||||
if (!abortController || abortController.signal.aborted || abortController.requestCompleted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
abortController.abort();
|
|
||||||
logger.debug('[EditController] Request aborted on close');
|
|
||||||
};
|
|
||||||
|
|
||||||
res.on('close', closeHandler);
|
|
||||||
cleanupHandlers.push(() => {
|
|
||||||
try {
|
|
||||||
res.removeListener('close', closeHandler);
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let response = await client.sendMessage(text, {
|
|
||||||
user: userId,
|
|
||||||
generation,
|
|
||||||
isContinued,
|
|
||||||
isEdited: true,
|
|
||||||
conversationId,
|
|
||||||
parentMessageId,
|
|
||||||
responseMessageId: reqDataContext.responseMessageId,
|
|
||||||
overrideParentMessageId,
|
|
||||||
getReqData: updateReqData,
|
|
||||||
onStart,
|
|
||||||
abortController,
|
|
||||||
progressCallback,
|
|
||||||
progressOptions: {
|
|
||||||
res,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const databasePromise = response.databasePromise;
|
|
||||||
delete response.databasePromise;
|
|
||||||
|
|
||||||
const { conversation: convoData = {} } = await databasePromise;
|
|
||||||
const conversation = { ...convoData };
|
|
||||||
conversation.title =
|
|
||||||
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
|
|
||||||
|
|
||||||
if (client?.options?.attachments && endpointOption?.modelOptions?.model) {
|
|
||||||
conversation.model = endpointOption.modelOptions.model;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!abortController.signal.aborted) {
|
|
||||||
const finalUserMessage = reqDataContext.userMessage;
|
|
||||||
const finalResponseMessage = { ...response };
|
|
||||||
|
|
||||||
sendEvent(res, {
|
|
||||||
final: true,
|
|
||||||
conversation,
|
|
||||||
title: conversation.title,
|
|
||||||
requestMessage: finalUserMessage,
|
|
||||||
responseMessage: finalResponseMessage,
|
|
||||||
});
|
|
||||||
res.end();
|
|
||||||
|
|
||||||
await saveMessage(
|
|
||||||
req,
|
|
||||||
{ ...finalResponseMessage, user: userId },
|
|
||||||
{ context: 'api/server/controllers/EditController.js - response end' },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
performCleanup();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[EditController] Error handling request', error);
|
|
||||||
let partialText = '';
|
|
||||||
try {
|
|
||||||
const currentClient = clientRef?.deref();
|
|
||||||
partialText =
|
|
||||||
currentClient?.getStreamText != null ? currentClient.getStreamText() : getPartialText();
|
|
||||||
} catch (getTextError) {
|
|
||||||
logger.error('[EditController] Error calling getText() during error handling', getTextError);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleAbortError(res, req, error, {
|
|
||||||
sender,
|
|
||||||
partialText,
|
|
||||||
conversationId,
|
|
||||||
messageId: reqDataContext.responseMessageId,
|
|
||||||
parentMessageId: overrideParentMessageId ?? userMessageId ?? parentMessageId,
|
|
||||||
userMessageId,
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
logger.error('[EditController] Error in `handleAbortError` during catch block', err);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
performCleanup();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = EditController;
|
|
||||||
99
api/server/controllers/FavoritesController.js
Normal file
99
api/server/controllers/FavoritesController.js
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
const { updateUser, getUserById } = require('~/models');
|
||||||
|
|
||||||
|
const MAX_FAVORITES = 50;
|
||||||
|
const MAX_STRING_LENGTH = 256;
|
||||||
|
|
||||||
|
const updateFavoritesController = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { favorites } = req.body;
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
if (!favorites) {
|
||||||
|
return res.status(400).json({ message: 'Favorites data is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(favorites)) {
|
||||||
|
return res.status(400).json({ message: 'Favorites must be an array' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (favorites.length > MAX_FAVORITES) {
|
||||||
|
return res.status(400).json({
|
||||||
|
code: 'MAX_FAVORITES_EXCEEDED',
|
||||||
|
message: `Maximum ${MAX_FAVORITES} favorites allowed`,
|
||||||
|
limit: MAX_FAVORITES,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fav of favorites) {
|
||||||
|
const hasAgent = !!fav.agentId;
|
||||||
|
const hasModel = !!(fav.model && fav.endpoint);
|
||||||
|
|
||||||
|
if (fav.agentId && fav.agentId.length > MAX_STRING_LENGTH) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ message: `agentId exceeds maximum length of ${MAX_STRING_LENGTH}` });
|
||||||
|
}
|
||||||
|
if (fav.model && fav.model.length > MAX_STRING_LENGTH) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ message: `model exceeds maximum length of ${MAX_STRING_LENGTH}` });
|
||||||
|
}
|
||||||
|
if (fav.endpoint && fav.endpoint.length > MAX_STRING_LENGTH) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ message: `endpoint exceeds maximum length of ${MAX_STRING_LENGTH}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAgent && !hasModel) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: 'Each favorite must have either agentId or model+endpoint',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAgent && hasModel) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: 'Favorite cannot have both agentId and model/endpoint',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await updateUser(userId, { favorites });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ message: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json(user.favorites);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating favorites:', error);
|
||||||
|
res.status(500).json({ message: 'Internal server error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFavoritesController = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const user = await getUserById(userId, 'favorites');
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ message: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let favorites = user.favorites || [];
|
||||||
|
|
||||||
|
if (!Array.isArray(favorites)) {
|
||||||
|
favorites = [];
|
||||||
|
await updateUser(userId, { favorites: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json(favorites);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching favorites:', error);
|
||||||
|
res.status(500).json({ message: 'Internal server error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
updateFavoritesController,
|
||||||
|
getFavoritesController,
|
||||||
|
};
|
||||||
|
|
@ -4,13 +4,15 @@
|
||||||
|
|
||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { ResourceType, PrincipalType } = require('librechat-data-provider');
|
const { ResourceType, PrincipalType, PermissionBits } = require('librechat-data-provider');
|
||||||
const {
|
const {
|
||||||
bulkUpdateResourcePermissions,
|
bulkUpdateResourcePermissions,
|
||||||
ensureGroupPrincipalExists,
|
ensureGroupPrincipalExists,
|
||||||
getEffectivePermissions,
|
getEffectivePermissions,
|
||||||
ensurePrincipalExists,
|
ensurePrincipalExists,
|
||||||
getAvailableRoles,
|
getAvailableRoles,
|
||||||
|
findAccessibleResources,
|
||||||
|
getResourcePermissionsMap,
|
||||||
} = require('~/server/services/PermissionService');
|
} = require('~/server/services/PermissionService');
|
||||||
const { AclEntry } = require('~/db/models');
|
const { AclEntry } = require('~/db/models');
|
||||||
const {
|
const {
|
||||||
|
|
@ -475,10 +477,58 @@ const searchPrincipals = async (req, res) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's effective permissions for all accessible resources of a type
|
||||||
|
* @route GET /api/permissions/{resourceType}/effective/all
|
||||||
|
*/
|
||||||
|
const getAllEffectivePermissions = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { resourceType } = req.params;
|
||||||
|
validateResourceType(resourceType);
|
||||||
|
|
||||||
|
const { id: userId } = req.user;
|
||||||
|
|
||||||
|
// Find all resources the user has at least VIEW access to
|
||||||
|
const accessibleResourceIds = await findAccessibleResources({
|
||||||
|
userId,
|
||||||
|
role: req.user.role,
|
||||||
|
resourceType,
|
||||||
|
requiredPermissions: PermissionBits.VIEW,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (accessibleResourceIds.length === 0) {
|
||||||
|
return res.status(200).json({});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get effective permissions for all accessible resources
|
||||||
|
const permissionsMap = await getResourcePermissionsMap({
|
||||||
|
userId,
|
||||||
|
role: req.user.role,
|
||||||
|
resourceType,
|
||||||
|
resourceIds: accessibleResourceIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert Map to plain object for JSON response
|
||||||
|
const result = {};
|
||||||
|
for (const [resourceId, permBits] of permissionsMap) {
|
||||||
|
result[resourceId] = permBits;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting all effective permissions:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to get all effective permissions',
|
||||||
|
details: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
updateResourcePermissions,
|
updateResourcePermissions,
|
||||||
getResourcePermissions,
|
getResourcePermissions,
|
||||||
getResourceRoles,
|
getResourceRoles,
|
||||||
getUserEffectivePermissions,
|
getUserEffectivePermissions,
|
||||||
|
getAllEffectivePermissions,
|
||||||
searchPrincipals,
|
searchPrincipals,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
const { encryptV3 } = require('@librechat/api');
|
const { encryptV3, logger } = require('@librechat/data-schemas');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
|
||||||
const {
|
const {
|
||||||
verifyTOTP,
|
|
||||||
getTOTPSecret,
|
|
||||||
verifyBackupCode,
|
|
||||||
generateTOTPSecret,
|
|
||||||
generateBackupCodes,
|
generateBackupCodes,
|
||||||
|
generateTOTPSecret,
|
||||||
|
verifyBackupCode,
|
||||||
|
getTOTPSecret,
|
||||||
|
verifyTOTP,
|
||||||
} = require('~/server/services/twoFactorService');
|
} = require('~/server/services/twoFactorService');
|
||||||
const { getUserById, updateUser } = require('~/models');
|
const { getUserById, updateUser } = require('~/models');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,17 @@ const { Tools, CacheKeys, Constants, FileSources } = require('librechat-data-pro
|
||||||
const {
|
const {
|
||||||
MCPOAuthHandler,
|
MCPOAuthHandler,
|
||||||
MCPTokenStorage,
|
MCPTokenStorage,
|
||||||
mcpServersRegistry,
|
|
||||||
normalizeHttpError,
|
normalizeHttpError,
|
||||||
extractWebSearchEnvVars,
|
extractWebSearchEnvVars,
|
||||||
} = require('@librechat/api');
|
} = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
deleteAllUserSessions,
|
deleteAllUserSessions,
|
||||||
deleteAllSharedLinks,
|
deleteAllSharedLinks,
|
||||||
|
updateUserPlugins,
|
||||||
deleteUserById,
|
deleteUserById,
|
||||||
deleteMessages,
|
deleteMessages,
|
||||||
deletePresets,
|
deletePresets,
|
||||||
|
deleteUserKey,
|
||||||
deleteConvos,
|
deleteConvos,
|
||||||
deleteFiles,
|
deleteFiles,
|
||||||
updateUser,
|
updateUser,
|
||||||
|
|
@ -32,11 +33,10 @@ const {
|
||||||
User,
|
User,
|
||||||
} = require('~/db/models');
|
} = require('~/db/models');
|
||||||
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
|
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
|
||||||
const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService');
|
|
||||||
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
|
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
|
||||||
|
const { getMCPManager, getFlowStateManager, getMCPServersRegistry } = require('~/config');
|
||||||
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
|
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
|
||||||
const { processDeleteRequest } = require('~/server/services/Files/process');
|
const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
|
||||||
const { getAppConfig } = require('~/server/services/Config');
|
const { getAppConfig } = require('~/server/services/Config');
|
||||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||||
const { deleteUserPrompts } = require('~/models/Prompt');
|
const { deleteUserPrompts } = require('~/models/Prompt');
|
||||||
|
|
@ -115,13 +115,7 @@ const updateUserPluginsController = async (req, res) => {
|
||||||
const { pluginKey, action, auth, isEntityTool } = req.body;
|
const { pluginKey, action, auth, isEntityTool } = req.body;
|
||||||
try {
|
try {
|
||||||
if (!isEntityTool) {
|
if (!isEntityTool) {
|
||||||
const userPluginsService = await updateUserPluginsService(user, pluginKey, action);
|
await updateUserPlugins(user._id, user.plugins, pluginKey, action);
|
||||||
|
|
||||||
if (userPluginsService instanceof Error) {
|
|
||||||
logger.error('[userPluginsService]', userPluginsService);
|
|
||||||
const { status, message } = normalizeHttpError(userPluginsService);
|
|
||||||
return res.status(status).send({ message });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth == null) {
|
if (auth == null) {
|
||||||
|
|
@ -321,9 +315,9 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
|
||||||
|
|
||||||
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
|
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
|
||||||
const serverConfig =
|
const serverConfig =
|
||||||
(await mcpServersRegistry.getServerConfig(serverName, userId)) ??
|
(await getMCPServersRegistry().getServerConfig(serverName, userId)) ??
|
||||||
appConfig?.mcpServers?.[serverName];
|
appConfig?.mcpServers?.[serverName];
|
||||||
const oauthServers = await mcpServersRegistry.getOAuthServers();
|
const oauthServers = await getMCPServersRegistry().getOAuthServers(userId);
|
||||||
if (!oauthServers.has(serverName)) {
|
if (!oauthServers.has(serverName)) {
|
||||||
// this server does not use OAuth, so nothing to do here as well
|
// this server does not use OAuth, so nothing to do here as well
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -73,10 +73,10 @@ describe('createToolEndCallback', () => {
|
||||||
tool_call_id: 'tool123',
|
tool_call_id: 'tool123',
|
||||||
artifact: {
|
artifact: {
|
||||||
[Tools.ui_resources]: {
|
[Tools.ui_resources]: {
|
||||||
data: {
|
data: [
|
||||||
0: { type: 'button', label: 'Click me' },
|
{ type: 'button', label: 'Click me' },
|
||||||
1: { type: 'input', placeholder: 'Enter text' },
|
{ type: 'input', placeholder: 'Enter text' },
|
||||||
},
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -100,10 +100,10 @@ describe('createToolEndCallback', () => {
|
||||||
messageId: 'run456',
|
messageId: 'run456',
|
||||||
toolCallId: 'tool123',
|
toolCallId: 'tool123',
|
||||||
conversationId: 'thread789',
|
conversationId: 'thread789',
|
||||||
[Tools.ui_resources]: {
|
[Tools.ui_resources]: [
|
||||||
0: { type: 'button', label: 'Click me' },
|
{ type: 'button', label: 'Click me' },
|
||||||
1: { type: 'input', placeholder: 'Enter text' },
|
{ type: 'input', placeholder: 'Enter text' },
|
||||||
},
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -115,9 +115,7 @@ describe('createToolEndCallback', () => {
|
||||||
tool_call_id: 'tool123',
|
tool_call_id: 'tool123',
|
||||||
artifact: {
|
artifact: {
|
||||||
[Tools.ui_resources]: {
|
[Tools.ui_resources]: {
|
||||||
data: {
|
data: [{ type: 'carousel', items: [] }],
|
||||||
0: { type: 'carousel', items: [] },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -136,9 +134,7 @@ describe('createToolEndCallback', () => {
|
||||||
messageId: 'run456',
|
messageId: 'run456',
|
||||||
toolCallId: 'tool123',
|
toolCallId: 'tool123',
|
||||||
conversationId: 'thread789',
|
conversationId: 'thread789',
|
||||||
[Tools.ui_resources]: {
|
[Tools.ui_resources]: [{ type: 'carousel', items: [] }],
|
||||||
0: { type: 'carousel', items: [] },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -155,9 +151,7 @@ describe('createToolEndCallback', () => {
|
||||||
tool_call_id: 'tool123',
|
tool_call_id: 'tool123',
|
||||||
artifact: {
|
artifact: {
|
||||||
[Tools.ui_resources]: {
|
[Tools.ui_resources]: {
|
||||||
data: {
|
data: [{ type: 'test' }],
|
||||||
0: { type: 'test' },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -184,9 +178,7 @@ describe('createToolEndCallback', () => {
|
||||||
tool_call_id: 'tool123',
|
tool_call_id: 'tool123',
|
||||||
artifact: {
|
artifact: {
|
||||||
[Tools.ui_resources]: {
|
[Tools.ui_resources]: {
|
||||||
data: {
|
data: [{ type: 'chart', data: [] }],
|
||||||
0: { type: 'chart', data: [] },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
[Tools.web_search]: {
|
[Tools.web_search]: {
|
||||||
results: ['result1', 'result2'],
|
results: ['result1', 'result2'],
|
||||||
|
|
@ -209,9 +201,7 @@ describe('createToolEndCallback', () => {
|
||||||
// Check ui_resources attachment
|
// Check ui_resources attachment
|
||||||
const uiResourceAttachment = results.find((r) => r?.type === Tools.ui_resources);
|
const uiResourceAttachment = results.find((r) => r?.type === Tools.ui_resources);
|
||||||
expect(uiResourceAttachment).toBeTruthy();
|
expect(uiResourceAttachment).toBeTruthy();
|
||||||
expect(uiResourceAttachment[Tools.ui_resources]).toEqual({
|
expect(uiResourceAttachment[Tools.ui_resources]).toEqual([{ type: 'chart', data: [] }]);
|
||||||
0: { type: 'chart', data: [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check web_search attachment
|
// Check web_search attachment
|
||||||
const webSearchAttachment = results.find((r) => r?.type === Tools.web_search);
|
const webSearchAttachment = results.find((r) => r?.type === Tools.web_search);
|
||||||
|
|
@ -250,7 +240,7 @@ describe('createToolEndCallback', () => {
|
||||||
tool_call_id: 'tool123',
|
tool_call_id: 'tool123',
|
||||||
artifact: {
|
artifact: {
|
||||||
[Tools.ui_resources]: {
|
[Tools.ui_resources]: {
|
||||||
data: {},
|
data: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -268,7 +258,7 @@ describe('createToolEndCallback', () => {
|
||||||
messageId: 'run456',
|
messageId: 'run456',
|
||||||
toolCallId: 'tool123',
|
toolCallId: 'tool123',
|
||||||
conversationId: 'thread789',
|
conversationId: 'thread789',
|
||||||
[Tools.ui_resources]: {},
|
[Tools.ui_resources]: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue