diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
index e7c36c5535..70ebf9b955 100644
--- a/.devcontainer/docker-compose.yml
+++ b/.devcontainer/docker-compose.yml
@@ -20,8 +20,7 @@ services:
environment:
- HOST=0.0.0.0
- 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/chat/completions # if you are hosting your own chatgpt reverse proxy with docker
+ # - OPENAI_REVERSE_PROXY=http://host.docker.internal:8070/v1
- MEILI_HOST=http://meilisearch:7700
# Runs app on the same network as the service container, allows "forwardPorts" in devcontainer.json function.
diff --git a/.env.example b/.env.example
index 90995be72f..e746737ea4 100644
--- a/.env.example
+++ b/.env.example
@@ -47,6 +47,10 @@ TRUST_PROXY=1
# password policies.
# MIN_PASSWORD_LENGTH=8
+# When enabled, the app will continue running after encountering uncaught exceptions
+# instead of exiting the process. Not recommended for production unless necessary.
+# CONTINUE_ON_UNCAUGHT_EXCEPTION=false
+
#===============#
# JSON Logging #
#===============#
@@ -61,6 +65,9 @@ CONSOLE_JSON=false
DEBUG_LOGGING=true
DEBUG_CONSOLE=false
+# Enable memory diagnostics (logs heap/RSS snapshots every 60s, auto-enabled with --inspect)
+# MEM_DIAG=true
+
#=============#
# Permissions #
#=============#
@@ -68,6 +75,18 @@ DEBUG_CONSOLE=false
# UID=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 #
#===============#
@@ -75,6 +94,16 @@ DEBUG_CONSOLE=false
# CONFIG_PATH="/alternative/path/to/librechat.yaml"
+#==================#
+# Langfuse Tracing #
+#==================#
+
+# Get Langfuse API keys for your project from the project settings page: https://cloud.langfuse.com
+
+# LANGFUSE_PUBLIC_KEY=
+# LANGFUSE_SECRET_KEY=
+# LANGFUSE_BASE_URL=
+
#===================================================#
# Endpoints #
#===================================================#
@@ -109,9 +138,13 @@ PROXY=
#============#
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-sonnet-4-6,claude-opus-4-6,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=
+# Set to true to use Anthropic models through Google Vertex AI instead of direct API
+# ANTHROPIC_USE_VERTEX=
+# ANTHROPIC_VERTEX_REGION=us-east5
+
#============#
# Azure #
#============#
@@ -129,7 +162,6 @@ ANTHROPIC_API_KEY=user_provided
# AZURE_OPENAI_API_VERSION= # Deprecated
# AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME= # Deprecated
# AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME= # Deprecated
-# PLUGINS_USE_AZURE="true" # Deprecated
#=================#
# AWS Bedrock #
@@ -141,7 +173,8 @@ ANTHROPIC_API_KEY=user_provided
# BEDROCK_AWS_SESSION_TOKEN=someSessionToken
# Note: This example list is not meant to be exhaustive. If omitted, all known, supported model IDs will be included for you.
-# BEDROCK_AWS_MODELS=anthropic.claude-3-5-sonnet-20240620-v1:0,meta.llama3-1-8b-instruct-v1:0
+# BEDROCK_AWS_MODELS=anthropic.claude-sonnet-4-6,anthropic.claude-opus-4-6-v1,anthropic.claude-3-5-sonnet-20240620-v1:0,meta.llama3-1-8b-instruct-v1:0
+# Cross-region inference model IDs: us.anthropic.claude-sonnet-4-6,us.anthropic.claude-opus-4-6-v1,global.anthropic.claude-opus-4-6-v1
# See all Bedrock model IDs here: https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids.html#model-ids-arns
@@ -163,15 +196,23 @@ GOOGLE_KEY=user_provided
# GOOGLE_AUTH_HEADER=true
# Gemini API (AI Studio)
-# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash,gemini-2.0-flash-lite
+# GOOGLE_MODELS=gemini-3.1-pro-preview,gemini-3.1-pro-preview-customtools,gemini-3.1-flash-lite-preview,gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash,gemini-2.0-flash-lite
# Vertex AI
-# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash-001,gemini-2.0-flash-lite-001
+# GOOGLE_MODELS=gemini-3.1-pro-preview,gemini-3.1-pro-preview-customtools,gemini-3.1-flash-lite-preview,gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash-001,gemini-2.0-flash-lite-001
# GOOGLE_TITLE_MODEL=gemini-2.0-flash-lite-001
+# Google Cloud region for Vertex AI (used by both chat and image generation)
# 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
# NOTE: These settings apply to both Vertex AI and Gemini API (AI Studio)
#
@@ -191,6 +232,23 @@ GOOGLE_KEY=user_provided
# GOOGLE_SAFETY_DANGEROUS_CONTENT=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
+
+# Vertex AI model for image generation (defaults to gemini-2.5-flash-image)
+# GEMINI_IMAGE_MODEL=gemini-2.5-flash-image
+
#============#
# OpenAI #
#============#
@@ -230,14 +288,6 @@ ASSISTANTS_API_KEY=user_provided
# 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
-#============#
-# 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_IV=e2341419ec3dd3d19b13a1a87fafcbfb
@@ -257,6 +307,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_BASEURL= # Custom OpenAI base URL for image generation tool
# 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_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
@@ -294,10 +345,6 @@ FLUX_API_BASE_URL=https://api.us1.bfl.ai
GOOGLE_SEARCH_API_KEY=
GOOGLE_CSE_ID=
-# YOUTUBE
-#-----------------
-YOUTUBE_API_KEY=
-
# Stable Diffusion
#-----------------
SD_WEBUI_URL=http://host.docker.internal:7860
@@ -466,6 +513,9 @@ OPENID_ADMIN_ROLE_TOKEN_KIND=
OPENID_USERNAME_CLAIM=
# Set to determine which user info property returned from OpenID Provider to store as the User's name
OPENID_NAME_CLAIM=
+# Set to determine which user info claim to use as the email/identifier for user matching (e.g., "upn" for Entra ID)
+# When not set, defaults to: email -> preferred_username -> upn
+OPENID_EMAIL_CLAIM=
# Optional audience parameter for OpenID authorization requests
OPENID_AUDIENCE=
@@ -488,6 +538,8 @@ OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED=
OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE="user.read" # example for Scope Needed for Microsoft Graph API
# Set to true to use the OpenID Connect end session endpoint for logout
OPENID_USE_END_SESSION_ENDPOINT=
+# URL to redirect to after OpenID logout (defaults to ${DOMAIN_CLIENT}/login)
+OPENID_POST_LOGOUT_REDIRECT_URI=
#========================#
# SharePoint Integration #
@@ -608,6 +660,9 @@ AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=
AWS_BUCKET_NAME=
+# Required for path-style S3-compatible providers (MinIO, Hetzner, Backblaze B2, etc.)
+# that don't support virtual-hosted-style URLs (bucket.endpoint). Not needed for AWS S3.
+# AWS_FORCE_PATH_STYLE=false
#========================#
# Azure Blob Storage #
@@ -622,7 +677,8 @@ AZURE_CONTAINER_NAME=files
#========================#
ALLOW_SHARED_LINKS=true
-ALLOW_SHARED_LINKS_PUBLIC=true
+# Allows unauthenticated access to shared links. Defaults to false (auth required) if not set.
+ALLOW_SHARED_LINKS_PUBLIC=false
#==============================#
# Static File Cache Control #
@@ -665,6 +721,9 @@ HELP_AND_FAQ_URL=https://librechat.ai
# Enable Redis for caching and session storage
# 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
# REDIS_URI=redis://127.0.0.1:6379
@@ -699,8 +758,10 @@ HELP_AND_FAQ_URL=https://librechat.ai
# REDIS_PING_INTERVAL=300
# Force specific cache namespaces to use in-memory storage even when Redis is enabled
-# Comma-separated list of CacheKeys (e.g., ROLES,MESSAGES)
-# FORCED_IN_MEMORY_CACHE_NAMESPACES=ROLES,MESSAGES
+# Comma-separated list of CacheKeys
+# Defaults to CONFIG_STORE,APP_CONFIG so YAML-derived config stays per-container (safe for blue/green deployments)
+# Set to empty string to force all namespaces through Redis: FORCED_IN_MEMORY_CACHE_NAMESPACES=
+# FORCED_IN_MEMORY_CACHE_NAMESPACES=CONFIG_STORE,APP_CONFIG
# Leader Election Configuration (for multi-instance deployments with Redis)
# Duration in seconds that the leader lease is valid before it expires (default: 25)
@@ -789,3 +850,24 @@ OPENWEATHER_API_KEY=
# Skip code challenge method validation (e.g., for AWS Cognito that supports S256 but doesn't advertise it)
# When set to true, forces S256 code challenge even if not advertised in .well-known/openid-configuration
# MCP_SKIP_CODE_CHALLENGE_CHECK=false
+
+# Circuit breaker: max connect/disconnect cycles before tripping (per server)
+# MCP_CB_MAX_CYCLES=7
+
+# Circuit breaker: sliding window (ms) for counting cycles
+# MCP_CB_CYCLE_WINDOW_MS=45000
+
+# Circuit breaker: cooldown (ms) after the cycle breaker trips
+# MCP_CB_CYCLE_COOLDOWN_MS=15000
+
+# Circuit breaker: max consecutive failed connection rounds before backoff
+# MCP_CB_MAX_FAILED_ROUNDS=3
+
+# Circuit breaker: sliding window (ms) for counting failed rounds
+# MCP_CB_FAILED_WINDOW_MS=120000
+
+# Circuit breaker: base backoff (ms) after failed round threshold is reached
+# MCP_CB_BASE_BACKOFF_MS=30000
+
+# Circuit breaker: max backoff cap (ms) for exponential backoff
+# MCP_CB_MAX_BACKOFF_MS=300000
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index ad0a75ab9b..ae9e6d8e4b 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -26,18 +26,14 @@ Project maintainers have the right and responsibility to remove, edit, or reject
## 1. Development Setup
-1. Use Node.JS 20.x.
-2. Install typescript globally: `npm i -g typescript`.
-3. Run `npm ci` to install dependencies.
-4. Build the data provider: `npm run build:data-provider`.
-5. Build data schemas: `npm run build:data-schemas`.
-6. Build API methods: `npm run build:api`.
-7. Setup and run unit tests:
+1. Use Node.js v20.19.0+ or ^22.12.0 or >= 23.0.0.
+2. Run `npm run smart-reinstall` to install dependencies (uses Turborepo). Use `npm run reinstall` for a clean install, or `npm ci` for a fresh lockfile-based install.
+3. Build all compiled code: `npm run build`.
+4. Setup and run unit tests:
- Copy `.env.test`: `cp api/test/.env.test.example api/test/.env.test`.
- Run backend unit tests: `npm run test:api`.
- Run frontend unit tests: `npm run test:client`.
-8. Setup and run integration tests:
- - Build client: `cd client && npm run build`.
+5. Setup and run integration tests:
- Create `.env`: `cp .env.example .env`.
- Install [MongoDB Community Edition](https://www.mongodb.com/docs/manual/administration/install-community/), ensure that `mongosh` connects to your local instance.
- Run: `npx install playwright`, then `npx playwright install`.
@@ -48,11 +44,11 @@ Project maintainers have the right and responsibility to remove, edit, or reject
## 2. Development Notes
1. Before starting work, make sure your main branch has the latest commits with `npm run update`.
-3. Run linting command to find errors: `npm run lint`. Alternatively, ensure husky pre-commit checks are functioning.
+2. Run linting command to find errors: `npm run lint`. Alternatively, ensure husky pre-commit checks are functioning.
3. After your changes, reinstall packages in your current branch using `npm run reinstall` and ensure everything still works.
- Restart the ESLint server ("ESLint: Restart ESLint Server" in VS Code command bar) and your IDE after reinstalling or updating.
4. Clear web app localStorage and cookies before and after changes.
-5. For frontend changes, compile typescript before and after changes to check for introduced errors: `cd client && npm run build`.
+5. To check for introduced errors, build all compiled code: `npm run build`.
6. Run backend unit tests: `npm run test:api`.
7. Run frontend unit tests: `npm run test:client`.
8. Run integration tests: `npm run e2e`.
@@ -118,50 +114,45 @@ Apply the following naming conventions to branches, labels, and other Git-relate
- **JS/TS:** Directories and file names: Descriptive and camelCase. First letter uppercased for React files (e.g., `helperFunction.ts, ReactComponent.tsx`).
- **Docs:** Directories and file names: Descriptive and snake_case (e.g., `config_files.md`).
-## 7. TypeScript Conversion
+## 7. Coding Standards
+
+For detailed coding conventions, workspace boundaries, and architecture guidance, refer to the [`AGENTS.md`](../AGENTS.md) file at the project root. It covers code style, type safety, import ordering, iteration/performance expectations, frontend rules, testing, and development commands.
+
+## 8. TypeScript Conversion
1. **Original State**: The project was initially developed entirely in JavaScript (JS).
-2. **Frontend Transition**:
- - We are in the process of transitioning the frontend from JS to TypeScript (TS).
- - The transition is nearing completion.
- - This conversion is feasible due to React's capability to intermix JS and TS prior to code compilation. It's standard practice to compile/bundle the code in such scenarios.
+2. **Frontend**: Fully transitioned to TypeScript.
-3. **Backend Considerations**:
- - Transitioning the backend to TypeScript would be a more intricate process, especially for an established Express.js server.
-
- - **Options for Transition**:
- - **Single Phase Overhaul**: This involves converting the entire backend to TypeScript in one go. It's the most straightforward approach but can be disruptive, especially for larger codebases.
-
- - **Incremental Transition**: Convert parts of the backend progressively. This can be done by:
- - Maintaining a separate directory for TypeScript files.
- - Gradually migrating and testing individual modules or routes.
- - Using a build tool like `tsc` to compile TypeScript files independently until the entire transition is complete.
-
- - **Compilation Considerations**:
- - Introducing a compilation step for the server is an option. This would involve using tools like `ts-node` for development and `tsc` for production builds.
- - However, this is not a conventional approach for Express.js servers and could introduce added complexity, especially in terms of build and deployment processes.
-
- - **Current Stance**: At present, this backend transition is of lower priority and might not be pursued.
+3. **Backend**:
+ - The legacy Express.js server remains in `/api` as JavaScript.
+ - All new backend code is written in TypeScript under `/packages/api`, which is compiled and consumed by `/api`.
+ - Shared database logic lives in `/packages/data-schemas` (TypeScript).
+ - Shared frontend/backend API types and services live in `/packages/data-provider` (TypeScript).
+ - Minimize direct changes to `/api`; prefer adding TypeScript code to `/packages/api` and importing it.
-## 8. Module Import Conventions
+## 9. Module Import Conventions
-- `npm` packages first,
- - from longest line (top) to shortest (bottom)
+Imports are organized into three sections (in order):
-- Followed by typescript types (pertains to data-provider and client workspaces)
- - longest line (top) to shortest (bottom)
- - types from package come first
+1. **Package imports** — sorted from shortest to longest line length.
+ - `react` is always the first import.
+ - Multi-line (stacked) imports count their total character length across all lines for sorting.
-- Lastly, local imports
- - longest line (top) to shortest (bottom)
- - imports with alias `~` treated the same as relative import with respect to line length
+2. **`import type` imports** — sorted from longest to shortest line length.
+ - Package type imports come first, then local type imports.
+ - Line length sorting resets between the package and local sub-groups.
+
+3. **Local/project imports** — sorted from longest to shortest line length.
+ - Multi-line (stacked) imports count their total character length across all lines for sorting.
+ - Imports with alias `~` are treated the same as relative imports with respect to line length.
+
+- Consolidate value imports from the same module as much as possible.
+- Always use standalone `import type { ... }` for type imports; never use inline `type` keyword inside value imports (e.g., `import { Foo, type Bar }` is wrong).
**Note:** ESLint will automatically enforce these import conventions when you run `npm run lint --fix` or through pre-commit hooks.
----
-
-Please ensure that you adapt this summary to fit the specific context and nuances of your project.
+For the full set of coding standards, see [`AGENTS.md`](../AGENTS.md).
---
diff --git a/.github/workflows/backend-review.yml b/.github/workflows/backend-review.yml
index 4f6fab329b..038c90627e 100644
--- a/.github/workflows/backend-review.yml
+++ b/.github/workflows/backend-review.yml
@@ -4,51 +4,150 @@ on:
branches:
- main
- dev
+ - dev-staging
- release/*
paths:
- 'api/**'
- 'packages/**'
+
+env:
+ NODE_ENV: CI
+ NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
+
jobs:
- tests_Backend:
- name: Run Backend unit tests
- timeout-minutes: 60
+ build:
+ name: Build packages
runs-on: ubuntu-latest
- env:
- MONGO_URI: ${{ secrets.MONGO_URI }}
- OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- JWT_SECRET: ${{ secrets.JWT_SECRET }}
- CREDS_KEY: ${{ secrets.CREDS_KEY }}
- CREDS_IV: ${{ secrets.CREDS_IV }}
- BAN_VIOLATIONS: ${{ secrets.BAN_VIOLATIONS }}
- BAN_DURATION: ${{ secrets.BAN_DURATION }}
- BAN_INTERVAL: ${{ secrets.BAN_INTERVAL }}
- NODE_ENV: CI
+ timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- - name: Use Node.js 20.x
+
+ - name: Use Node.js 20.19
uses: actions/setup-node@v4
with:
- node-version: 20
- cache: 'npm'
+ node-version: '20.19'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v4
+ with:
+ path: |
+ node_modules
+ api/node_modules
+ packages/api/node_modules
+ packages/data-provider/node_modules
+ packages/data-schemas/node_modules
+ key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
- - name: Install Data Provider Package
+ - name: Restore data-provider build cache
+ id: cache-data-provider
+ uses: actions/cache@v4
+ with:
+ path: packages/data-provider/dist
+ key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }}
+
+ - name: Build data-provider
+ if: steps.cache-data-provider.outputs.cache-hit != 'true'
run: npm run build:data-provider
- - name: Install Data Schemas Package
+ - name: Restore data-schemas build cache
+ id: cache-data-schemas
+ uses: actions/cache@v4
+ with:
+ path: packages/data-schemas/dist
+ key: build-data-schemas-${{ runner.os }}-${{ hashFiles('packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/rollup.config.js', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }}
+
+ - name: Build data-schemas
+ if: steps.cache-data-schemas.outputs.cache-hit != 'true'
run: npm run build:data-schemas
- - name: Install API Package
+ - name: Restore api build cache
+ id: cache-api
+ uses: actions/cache@v4
+ with:
+ path: packages/api/dist
+ key: build-api-${{ runner.os }}-${{ hashFiles('packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/server-rollup.config.js', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/rollup.config.js', 'packages/data-schemas/package.json') }}
+
+ - name: Build api
+ if: steps.cache-api.outputs.cache-hit != 'true'
run: npm run build:api
- - name: Create empty auth.json file
- run: |
- mkdir -p api/data
- echo '{}' > api/data/auth.json
+ - name: Upload data-provider build
+ uses: actions/upload-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+ retention-days: 2
- - name: Check for Circular dependency in rollup
+ - name: Upload data-schemas build
+ uses: actions/upload-artifact@v4
+ with:
+ name: build-data-schemas
+ path: packages/data-schemas/dist
+ retention-days: 2
+
+ - name: Upload api build
+ uses: actions/upload-artifact@v4
+ with:
+ name: build-api
+ path: packages/api/dist
+ retention-days: 2
+
+ circular-deps:
+ name: Circular dependency checks
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 20.19
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20.19'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v4
+ with:
+ path: |
+ node_modules
+ api/node_modules
+ packages/api/node_modules
+ packages/data-provider/node_modules
+ packages/data-schemas/node_modules
+ key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Download data-provider build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+
+ - name: Download data-schemas build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-schemas
+ path: packages/data-schemas/dist
+
+ - name: Rebuild @librechat/api and check for circular dependencies
+ run: |
+ output=$(npm run build:api 2>&1)
+ echo "$output"
+ if echo "$output" | grep -q "Circular depend"; then
+ echo "Error: Circular dependency detected in @librechat/api!"
+ exit 1
+ fi
+
+ - name: Detect circular dependencies in rollup
working-directory: ./packages/data-provider
run: |
output=$(npm run rollup:api)
@@ -58,17 +157,201 @@ jobs:
exit 1
fi
+ test-api:
+ name: 'Tests: api'
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ env:
+ MONGO_URI: ${{ secrets.MONGO_URI }}
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+ JWT_SECRET: ${{ secrets.JWT_SECRET }}
+ CREDS_KEY: ${{ secrets.CREDS_KEY }}
+ CREDS_IV: ${{ secrets.CREDS_IV }}
+ BAN_VIOLATIONS: ${{ secrets.BAN_VIOLATIONS }}
+ BAN_DURATION: ${{ secrets.BAN_DURATION }}
+ BAN_INTERVAL: ${{ secrets.BAN_INTERVAL }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 20.19
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20.19'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v4
+ with:
+ path: |
+ node_modules
+ api/node_modules
+ packages/api/node_modules
+ packages/data-provider/node_modules
+ packages/data-schemas/node_modules
+ key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Download data-provider build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+
+ - name: Download data-schemas build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-schemas
+ path: packages/data-schemas/dist
+
+ - name: Download api build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-api
+ path: packages/api/dist
+
+ - name: Create empty auth.json file
+ run: |
+ mkdir -p api/data
+ echo '{}' > api/data/auth.json
+
- name: Prepare .env.test file
run: cp api/test/.env.test.example api/test/.env.test
- name: Run unit tests
run: cd api && npm run test:ci
- - name: Run librechat-data-provider unit tests
+ test-data-provider:
+ name: 'Tests: data-provider'
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 20.19
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20.19'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v4
+ with:
+ path: |
+ node_modules
+ api/node_modules
+ packages/api/node_modules
+ packages/data-provider/node_modules
+ packages/data-schemas/node_modules
+ key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Download data-provider build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+
+ - name: Run unit tests
run: cd packages/data-provider && npm run test:ci
- - name: Run @librechat/data-schemas unit tests
+ test-data-schemas:
+ name: 'Tests: data-schemas'
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 20.19
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20.19'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v4
+ with:
+ path: |
+ node_modules
+ api/node_modules
+ packages/api/node_modules
+ packages/data-provider/node_modules
+ packages/data-schemas/node_modules
+ key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Download data-provider build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+
+ - name: Download data-schemas build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-schemas
+ path: packages/data-schemas/dist
+
+ - name: Run unit tests
run: cd packages/data-schemas && npm run test:ci
- - name: Run @librechat/api unit tests
- run: cd packages/api && npm run test:ci
\ No newline at end of file
+ test-packages-api:
+ name: 'Tests: @librechat/api'
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 20.19
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20.19'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v4
+ with:
+ path: |
+ node_modules
+ api/node_modules
+ packages/api/node_modules
+ packages/data-provider/node_modules
+ packages/data-schemas/node_modules
+ key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Download data-provider build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+
+ - name: Download data-schemas build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-schemas
+ path: packages/data-schemas/dist
+
+ - name: Download api build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-api
+ path: packages/api/dist
+
+ - name: Run unit tests
+ run: cd packages/api && npm run test:ci
diff --git a/.github/workflows/cache-integration-tests.yml b/.github/workflows/cache-integration-tests.yml
index 1f056dd791..caebbfc445 100644
--- a/.github/workflows/cache-integration-tests.yml
+++ b/.github/workflows/cache-integration-tests.yml
@@ -5,11 +5,13 @@ on:
branches:
- main
- dev
+ - dev-staging
- release/*
paths:
- 'packages/api/src/cache/**'
- 'packages/api/src/cluster/**'
- 'packages/api/src/mcp/**'
+ - 'packages/api/src/stream/**'
- 'redis-config/**'
- '.github/workflows/cache-integration-tests.yml'
@@ -86,4 +88,4 @@ jobs:
- name: Stop Single Redis Instance
if: always()
- run: redis-cli -p 6379 shutdown || true
\ No newline at end of file
+ run: redis-cli -p 6379 shutdown || true
diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml
index 6b97a1e61d..e3e3e445e4 100644
--- a/.github/workflows/client.yml
+++ b/.github/workflows/client.yml
@@ -13,9 +13,14 @@ on:
required: false
default: 'Manual publish requested'
+permissions:
+ id-token: write # Required for OIDC trusted publishing
+ contents: read
+
jobs:
build-and-publish:
runs-on: ubuntu-latest
+ environment: publish # Must match npm trusted publisher config
steps:
- uses: actions/checkout@v4
@@ -23,6 +28,10 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '20.x'
+ registry-url: 'https://registry.npmjs.org'
+
+ - name: Update npm for OIDC support
+ run: npm install -g npm@latest # Must be 11.5.1+ for provenance
- name: Install client dependencies
run: cd packages/client && npm ci
@@ -30,9 +39,6 @@ jobs:
- name: Build client
run: cd packages/client && npm run build
- - name: Set up npm authentication
- run: echo "//registry.npmjs.org/:_authToken=${{ secrets.PUBLISH_NPM_TOKEN }}" > ~/.npmrc
-
- name: Check version change
id: check
working-directory: packages/client
@@ -55,4 +61,4 @@ jobs:
- name: Publish
if: steps.check.outputs.skip != 'true'
working-directory: packages/client
- run: npm publish *.tgz --access public
\ No newline at end of file
+ run: npm publish *.tgz --access public --provenance
diff --git a/.github/workflows/data-provider.yml b/.github/workflows/data-provider.yml
index 6e451359a9..9a514b0076 100644
--- a/.github/workflows/data-provider.yml
+++ b/.github/workflows/data-provider.yml
@@ -13,6 +13,10 @@ on:
required: false
default: 'Manual publish requested'
+permissions:
+ id-token: write # Required for OIDC trusted publishing
+ contents: read
+
jobs:
build:
runs-on: ubuntu-latest
@@ -27,14 +31,17 @@ jobs:
publish-npm:
needs: build
runs-on: ubuntu-latest
+ environment: publish # Must match npm trusted publisher config
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
+
+ - name: Update npm for OIDC support
+ run: npm install -g npm@latest # Must be 11.5.1+ for provenance
+
- run: cd packages/data-provider && npm ci
- run: cd packages/data-provider && npm run build
- - run: cd packages/data-provider && npm publish
- env:
- NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
+ - run: cd packages/data-provider && npm publish --provenance
diff --git a/.github/workflows/data-schemas.yml b/.github/workflows/data-schemas.yml
index ee2d9c30d7..882dc4f4b6 100644
--- a/.github/workflows/data-schemas.yml
+++ b/.github/workflows/data-schemas.yml
@@ -13,9 +13,14 @@ on:
required: false
default: 'Manual publish requested'
+permissions:
+ id-token: write # Required for OIDC trusted publishing
+ contents: read
+
jobs:
build-and-publish:
runs-on: ubuntu-latest
+ environment: publish # Must match npm trusted publisher config
steps:
- uses: actions/checkout@v4
@@ -23,6 +28,10 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '20.x'
+ registry-url: 'https://registry.npmjs.org'
+
+ - name: Update npm for OIDC support
+ run: npm install -g npm@latest # Must be 11.5.1+ for provenance
- name: Install dependencies
run: cd packages/data-schemas && npm ci
@@ -30,9 +39,6 @@ jobs:
- name: 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
id: check
working-directory: packages/data-schemas
@@ -55,4 +61,4 @@ jobs:
- name: Publish
if: steps.check.outputs.skip != 'true'
working-directory: packages/data-schemas
- run: npm publish *.tgz --access public
\ No newline at end of file
+ run: npm publish *.tgz --access public --provenance
diff --git a/.github/workflows/dev-staging-images.yml b/.github/workflows/dev-staging-images.yml
new file mode 100644
index 0000000000..e63dc5f0af
--- /dev/null
+++ b/.github/workflows/dev-staging-images.yml
@@ -0,0 +1,66 @@
+name: Docker Dev Staging Images Build
+
+on:
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ include:
+ - target: api-build
+ file: Dockerfile.multi
+ image_name: lc-dev-staging-api
+ - target: node
+ file: Dockerfile
+ image_name: lc-dev-staging
+
+ steps:
+ # Check out the repository
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ # Set up QEMU
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ # Set up Docker Buildx
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ # Log in to GitHub Container Registry
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v2
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # Login to Docker Hub
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ # Prepare the environment
+ - name: Prepare environment
+ run: |
+ cp .env.example .env
+
+ # Build and push Docker images for each target
+ - name: Build and push Docker images
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ${{ matrix.file }}
+ push: true
+ tags: |
+ ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }}
+ ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.sha }}
+ ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
+ platforms: linux/amd64,linux/arm64
+ target: ${{ matrix.target }}
+
diff --git a/.github/workflows/eslint-ci.yml b/.github/workflows/eslint-ci.yml
index 9383dd939e..8203da4e8b 100644
--- a/.github/workflows/eslint-ci.yml
+++ b/.github/workflows/eslint-ci.yml
@@ -5,6 +5,7 @@ on:
branches:
- main
- dev
+ - dev-staging
- release/*
paths:
- 'api/**'
@@ -56,4 +57,4 @@ jobs:
# Run ESLint
npx eslint --no-error-on-unmatched-pattern \
--config eslint.config.mjs \
- $CHANGED_FILES
\ No newline at end of file
+ $CHANGED_FILES
diff --git a/.github/workflows/frontend-review.yml b/.github/workflows/frontend-review.yml
index 7064c18c13..9c2d4a37b1 100644
--- a/.github/workflows/frontend-review.yml
+++ b/.github/workflows/frontend-review.yml
@@ -2,55 +2,209 @@ name: Frontend Unit Tests
on:
pull_request:
- branches:
+ branches:
- main
- dev
+ - dev-staging
- release/*
paths:
- 'client/**'
- 'packages/data-provider/**'
+env:
+ NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
+
jobs:
- tests_frontend_ubuntu:
- name: Run frontend unit tests on Ubuntu
- timeout-minutes: 60
+ build:
+ name: Build packages
runs-on: ubuntu-latest
+ timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- - name: Use Node.js 20.x
+
+ - name: Use Node.js 20.19
uses: actions/setup-node@v4
with:
- node-version: 20
- cache: 'npm'
+ node-version: '20.19'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v4
+ with:
+ path: |
+ node_modules
+ client/node_modules
+ packages/client/node_modules
+ packages/data-provider/node_modules
+ key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
- - name: Build Client
- run: npm run frontend:ci
+ - name: Restore data-provider build cache
+ id: cache-data-provider
+ uses: actions/cache@v4
+ with:
+ path: packages/data-provider/dist
+ key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }}
+
+ - name: Build data-provider
+ if: steps.cache-data-provider.outputs.cache-hit != 'true'
+ run: npm run build:data-provider
+
+ - name: Restore client-package build cache
+ id: cache-client-package
+ uses: actions/cache@v4
+ with:
+ path: packages/client/dist
+ key: build-client-package-${{ runner.os }}-${{ hashFiles('packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/rollup.config.js', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }}
+
+ - name: Build client-package
+ if: steps.cache-client-package.outputs.cache-hit != 'true'
+ run: npm run build:client-package
+
+ - name: Upload data-provider build
+ uses: actions/upload-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+ retention-days: 2
+
+ - name: Upload client-package build
+ uses: actions/upload-artifact@v4
+ with:
+ name: build-client-package
+ path: packages/client/dist
+ retention-days: 2
+
+ test-ubuntu:
+ name: 'Tests: Ubuntu'
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 20.19
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20.19'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v4
+ with:
+ path: |
+ node_modules
+ client/node_modules
+ packages/client/node_modules
+ packages/data-provider/node_modules
+ key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Download data-provider build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+
+ - name: Download client-package build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-client-package
+ path: packages/client/dist
- name: Run unit tests
run: npm run test:ci --verbose
working-directory: client
- tests_frontend_windows:
- name: Run frontend unit tests on Windows
- timeout-minutes: 60
+ test-windows:
+ name: 'Tests: Windows'
+ needs: build
runs-on: windows-latest
+ timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- - name: Use Node.js 20.x
+
+ - name: Use Node.js 20.19
uses: actions/setup-node@v4
with:
- node-version: 20
- cache: 'npm'
+ node-version: '20.19'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v4
+ with:
+ path: |
+ node_modules
+ client/node_modules
+ packages/client/node_modules
+ packages/data-provider/node_modules
+ key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
- - name: Build Client
- run: npm run frontend:ci
+ - name: Download data-provider build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+
+ - name: Download client-package build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-client-package
+ path: packages/client/dist
- name: Run unit tests
run: npm run test:ci --verbose
- working-directory: client
\ No newline at end of file
+ working-directory: client
+
+ build-verify:
+ name: Vite build verification
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 20.19
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20.19'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v4
+ with:
+ path: |
+ node_modules
+ client/node_modules
+ packages/client/node_modules
+ packages/data-provider/node_modules
+ key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Download data-provider build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+
+ - name: Download client-package build
+ uses: actions/download-artifact@v4
+ with:
+ name: build-client-package
+ path: packages/client/dist
+
+ - name: Build client
+ run: cd client && npm run build:ci
diff --git a/.github/workflows/unused-packages.yml b/.github/workflows/unused-packages.yml
index 442925b69b..f67c1d23be 100644
--- a/.github/workflows/unused-packages.yml
+++ b/.github/workflows/unused-packages.yml
@@ -8,6 +8,7 @@ on:
- 'client/**'
- 'api/**'
- 'packages/client/**'
+ - 'packages/api/**'
jobs:
detect-unused-packages:
@@ -63,35 +64,45 @@ jobs:
extract_deps_from_code() {
local folder=$1
local output_file=$2
+
+ # Initialize empty output file
+ > "$output_file"
+
if [[ -d "$folder" ]]; then
- # Extract require() statements
- grep -rEho "require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)" "$folder" --include=\*.{js,ts,tsx,jsx,mjs,cjs} | \
- sed -E "s/require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)/\1/" > "$output_file"
+ # Extract require() statements (use explicit includes for portability)
+ grep -rEho "require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)" "$folder" \
+ --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
+ sed -E "s/require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)/\1/" >> "$output_file" || true
- # Extract ES6 imports - various patterns
- # import x from 'module'
- grep -rEho "import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" --include=\*.{js,ts,tsx,jsx,mjs,cjs} | \
- sed -E "s/import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file"
+ # Extract ES6 imports - import x from 'module'
+ grep -rEho "import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
+ --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
+ sed -E "s/import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
# import 'module' (side-effect imports)
- grep -rEho "import ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" --include=\*.{js,ts,tsx,jsx,mjs,cjs} | \
- sed -E "s/import ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file"
+ grep -rEho "import ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
+ --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
+ sed -E "s/import ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
# export { x } from 'module' or export * from 'module'
- grep -rEho "export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" --include=\*.{js,ts,tsx,jsx,mjs,cjs} | \
- sed -E "s/export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file"
+ grep -rEho "export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
+ --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
+ sed -E "s/export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
# import type { x } from 'module' (TypeScript)
- grep -rEho "import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" --include=\*.{ts,tsx} | \
- sed -E "s/import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file"
+ grep -rEho "import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
+ --include='*.ts' --include='*.tsx' 2>/dev/null | \
+ sed -E "s/import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
# Remove subpath imports but keep the base package
- # e.g., '@tanstack/react-query/devtools' becomes '@tanstack/react-query'
- sed -i -E 's|^(@?[a-zA-Z0-9-]+(/[a-zA-Z0-9-]+)?)/.*|\1|' "$output_file"
+ # For scoped packages: '@scope/pkg/subpath' -> '@scope/pkg'
+ # For regular packages: 'pkg/subpath' -> 'pkg'
+ # Scoped packages (must keep @scope/package, strip anything after)
+ sed -i -E 's|^(@[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/.*|\1|' "$output_file" 2>/dev/null || true
+ # Non-scoped packages (keep package name, strip subpath)
+ sed -i -E 's|^([a-zA-Z0-9_-]+)/.*|\1|' "$output_file" 2>/dev/null || true
sort -u "$output_file" -o "$output_file"
- else
- touch "$output_file"
fi
}
@@ -99,8 +110,10 @@ jobs:
extract_deps_from_code "client" client_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/api" packages_api_used_code.txt
- name: Get @librechat/client dependencies
id: get-librechat-client-deps
@@ -126,6 +139,30 @@ jobs:
touch librechat_client_deps.txt
fi
+ - name: Get @librechat/api dependencies
+ id: get-librechat-api-deps
+ run: |
+ if [[ -f "packages/api/package.json" ]]; then
+ # Get all dependencies from @librechat/api (dependencies, devDependencies, and peerDependencies)
+ DEPS=$(jq -r '.dependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "")
+ DEV_DEPS=$(jq -r '.devDependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "")
+ PEER_DEPS=$(jq -r '.peerDependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "")
+
+ # Combine all dependencies
+ echo "$DEPS" > librechat_api_deps.txt
+ echo "$DEV_DEPS" >> librechat_api_deps.txt
+ echo "$PEER_DEPS" >> librechat_api_deps.txt
+
+ # Also include dependencies that are imported in packages/api
+ cat packages_api_used_code.txt >> librechat_api_deps.txt
+
+ # Remove empty lines and sort
+ grep -v '^$' librechat_api_deps.txt | sort -u > temp_deps.txt
+ mv temp_deps.txt librechat_api_deps.txt
+ else
+ touch librechat_api_deps.txt
+ fi
+
- name: Extract Workspace Dependencies
id: extract-workspace-deps
run: |
@@ -184,8 +221,8 @@ jobs:
chmod -R 755 client
cd client
UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
- # Exclude dependencies used in scripts, code, and workspace packages
- UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../client_used_deps.txt ../client_used_code.txt ../client_workspace_deps.txt | sort) || echo "")
+ # Exclude dependencies used in scripts, code, workspace packages, and @librechat/client imports
+ UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../client_used_deps.txt ../client_used_code.txt ../client_workspace_deps.txt ../packages_client_used_code.txt ../librechat_client_deps.txt 2>/dev/null | sort -u) || echo "")
# Filter out false positives
UNUSED=$(echo "$UNUSED" | grep -v "^micromark-extension-llm-math$" || echo "")
echo "CLIENT_UNUSED<> $GITHUB_ENV
@@ -201,8 +238,8 @@ jobs:
chmod -R 755 api
cd api
UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
- # Exclude dependencies used in scripts, code, and workspace packages
- UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../api_used_deps.txt ../api_used_code.txt ../api_workspace_deps.txt | sort) || echo "")
+ # Exclude dependencies used in scripts, code, workspace packages, and @librechat/api imports
+ UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../api_used_deps.txt ../api_used_code.txt ../api_workspace_deps.txt ../packages_api_used_code.txt ../librechat_api_deps.txt 2>/dev/null | sort -u) || echo "")
echo "API_UNUSED<> $GITHUB_ENV
echo "$UNUSED" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
@@ -241,4 +278,4 @@ jobs:
- name: Fail workflow if unused dependencies found
if: env.ROOT_UNUSED != '' || env.CLIENT_UNUSED != '' || env.API_UNUSED != ''
- run: exit 1
\ No newline at end of file
+ run: exit 1
diff --git a/.gitignore b/.gitignore
index d173d26b60..86d4a3ddae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,7 @@ pids
# CI/CD data
test-image*
+dump.rdb
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
@@ -29,6 +30,9 @@ coverage
config/translations/stores/*
client/src/localization/languages/*_missing_keys.json
+# Turborepo
+.turbo
+
# Compiled Dirs (http://nodejs.org/api/addons.html)
build/
dist/
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000000..ec44607aa7
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,166 @@
+# LibreChat
+
+## Project Overview
+
+LibreChat is a monorepo with the following key workspaces:
+
+| Workspace | Language | Side | Dependency | Purpose |
+|---|---|---|---|---|
+| `/api` | JS (legacy) | Backend | `packages/api`, `packages/data-schemas`, `packages/data-provider`, `@librechat/agents` | Express server — minimize changes here |
+| `/packages/api` | **TypeScript** | Backend | `packages/data-schemas`, `packages/data-provider` | New backend code lives here (TS only, consumed by `/api`) |
+| `/packages/data-schemas` | TypeScript | Backend | `packages/data-provider` | Database models/schemas, shareable across backend projects |
+| `/packages/data-provider` | TypeScript | Shared | — | Shared API types, endpoints, data-service — used by both frontend and backend |
+| `/client` | TypeScript/React | Frontend | `packages/data-provider`, `packages/client` | Frontend SPA |
+| `/packages/client` | TypeScript | Frontend | `packages/data-provider` | Shared frontend utilities |
+
+The source code for `@librechat/agents` (major backend dependency, same team) is at `/home/danny/agentus`.
+
+---
+
+## Workspace Boundaries
+
+- **All new backend code must be TypeScript** in `/packages/api`.
+- Keep `/api` changes to the absolute minimum (thin JS wrappers calling into `/packages/api`).
+- Database-specific shared logic goes in `/packages/data-schemas`.
+- Frontend/backend shared API logic (endpoints, types, data-service) goes in `/packages/data-provider`.
+- Build data-provider from project root: `npm run build:data-provider`.
+
+---
+
+## Code Style
+
+### Structure and Clarity
+
+- **Never-nesting**: early returns, flat code, minimal indentation. Break complex operations into well-named helpers.
+- **Functional first**: pure functions, immutable data, `map`/`filter`/`reduce` over imperative loops. Only reach for OOP when it clearly improves domain modeling or state encapsulation.
+- **No dynamic imports** unless absolutely necessary.
+
+### DRY
+
+- Extract repeated logic into utility functions.
+- Reusable hooks / higher-order components for UI patterns.
+- Parameterized helpers instead of near-duplicate functions.
+- Constants for repeated values; configuration objects over duplicated init code.
+- Shared validators, centralized error handling, single source of truth for business rules.
+- Shared typing system with interfaces/types extending common base definitions.
+- Abstraction layers for external API interactions.
+
+### Iteration and Performance
+
+- **Minimize looping** — especially over shared data structures like message arrays, which are iterated frequently throughout the codebase. Every additional pass adds up at scale.
+- Consolidate sequential O(n) operations into a single pass whenever possible; never loop over the same collection twice if the work can be combined.
+- Choose data structures that reduce the need to iterate (e.g., `Map`/`Set` for lookups instead of `Array.find`/`Array.includes`).
+- Avoid unnecessary object creation; consider space-time tradeoffs.
+- Prevent memory leaks: careful with closures, dispose resources/event listeners, no circular references.
+
+### Type Safety
+
+- **Never use `any`**. Explicit types for all parameters, return values, and variables.
+- **Limit `unknown`** — avoid `unknown`, `Record`, and `as unknown as T` assertions. A `Record` almost always signals a missing explicit type definition.
+- **Don't duplicate types** — before defining a new type, check whether it already exists in the project (especially `packages/data-provider`). Reuse and extend existing types rather than creating redundant definitions.
+- Use union types, generics, and interfaces appropriately.
+- All TypeScript and ESLint warnings/errors must be addressed — do not leave unresolved diagnostics.
+
+### Comments and Documentation
+
+- Write self-documenting code; no inline comments narrating what code does.
+- JSDoc only for complex/non-obvious logic or intellisense on public APIs.
+- Single-line JSDoc for brief docs, multi-line for complex cases.
+- Avoid standalone `//` comments unless absolutely necessary.
+
+### Import Order
+
+Imports are organized into three sections:
+
+1. **Package imports** — sorted shortest to longest line length (`react` always first).
+2. **`import type` imports** — sorted longest to shortest (package types first, then local types; length resets between sub-groups).
+3. **Local/project imports** — sorted longest to shortest.
+
+Multi-line imports count total character length across all lines. Consolidate value imports from the same module. Always use standalone `import type { ... }` — never inline `type` inside value imports.
+
+### JS/TS Loop Preferences
+
+- **Limit looping as much as possible.** Prefer single-pass transformations and avoid re-iterating the same data.
+- `for (let i = 0; ...)` for performance-critical or index-dependent operations.
+- `for...of` for simple array iteration.
+- `for...in` only for object property enumeration.
+
+---
+
+## Frontend Rules (`client/src/**/*`)
+
+### Localization
+
+- All user-facing text must use `useLocalize()`.
+- Only update English keys in `client/src/locales/en/translation.json` (other languages are automated externally).
+- Semantic key prefixes: `com_ui_`, `com_assistants_`, etc.
+
+### Components
+
+- TypeScript for all React components with proper type imports.
+- Semantic HTML with ARIA labels (`role`, `aria-label`) for accessibility.
+- Group related components in feature directories (e.g., `SidePanel/Memories/`).
+- Use index files for clean exports.
+
+### Data Management
+
+- Feature hooks: `client/src/data-provider/[Feature]/queries.ts` → `[Feature]/index.ts` → `client/src/data-provider/index.ts`.
+- React Query (`@tanstack/react-query`) for all API interactions; proper query invalidation on mutations.
+- QueryKeys and MutationKeys in `packages/data-provider/src/keys.ts`.
+
+### Data-Provider Integration
+
+- Endpoints: `packages/data-provider/src/api-endpoints.ts`
+- Data service: `packages/data-provider/src/data-service.ts`
+- Types: `packages/data-provider/src/types/queries.ts`
+- Use `encodeURIComponent` for dynamic URL parameters.
+
+### Performance
+
+- Prioritize memory and speed efficiency at scale.
+- Cursor pagination for large datasets.
+- Proper dependency arrays to avoid unnecessary re-renders.
+- Leverage React Query caching and background refetching.
+
+---
+
+## Development Commands
+
+| Command | Purpose |
+|---|---|
+| `npm run smart-reinstall` | Install deps (if lockfile changed) + build via Turborepo |
+| `npm run reinstall` | Clean install — wipe `node_modules` and reinstall from scratch |
+| `npm run backend` | Start the backend server |
+| `npm run backend:dev` | Start backend with file watching (development) |
+| `npm run build` | Build all compiled code via Turborepo (parallel, cached) |
+| `npm run frontend` | Build all compiled code sequentially (legacy fallback) |
+| `npm run frontend:dev` | Start frontend dev server with HMR (port 3090, requires backend running) |
+| `npm run build:data-provider` | Rebuild `packages/data-provider` after changes |
+
+- Node.js: v20.19.0+ or ^22.12.0 or >= 23.0.0
+- Database: MongoDB
+- Backend runs on `http://localhost:3080/`; frontend dev server on `http://localhost:3090/`
+
+---
+
+## Testing
+
+- Framework: **Jest**, run per-workspace.
+- Run tests from their workspace directory: `cd api && npx jest `, `cd packages/api && npx jest `, etc.
+- Frontend tests: `__tests__` directories alongside components; use `test/layout-test-utils` for rendering.
+- Cover loading, success, and error states for UI/data flows.
+
+### Philosophy
+
+- **Real logic over mocks.** Exercise actual code paths with real dependencies. Mocking is a last resort.
+- **Spies over mocks.** Assert that real functions are called with expected arguments and frequency without replacing underlying logic.
+- **MongoDB**: use `mongodb-memory-server` for a real in-memory MongoDB instance. Test actual queries and schema validation, not mocked DB calls.
+- **MCP**: use real `@modelcontextprotocol/sdk` exports for servers, transports, and tool definitions. Mirror real scenarios, don't stub SDK internals.
+- Only mock what you cannot control: external HTTP APIs, rate-limited services, non-deterministic system calls.
+- Heavy mocking is a code smell, not a testing strategy.
+
+---
+
+## Formatting
+
+Fix all formatting lint errors (trailing spaces, tabs, newlines, indentation) using auto-fix when available. All TypeScript/ESLint warnings and errors **must** be resolved.
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index a8cb8282bd..0000000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,236 +0,0 @@
-# Changelog
-
-All notable changes to this project will be documented in this file.
-
-
-
-
-
-
-## [Unreleased]
-
-### ✨ New Features
-
-- ✨ feat: implement search parameter updates by **@mawburn** in [#7151](https://github.com/danny-avila/LibreChat/pull/7151)
-- 🎏 feat: Add MCP support for Streamable HTTP Transport by **@benverhees** in [#7353](https://github.com/danny-avila/LibreChat/pull/7353)
-- 🔒 feat: Add Content Security Policy using Helmet middleware by **@rubentalstra** in [#7377](https://github.com/danny-avila/LibreChat/pull/7377)
-- ✨ feat: Add Normalization for MCP Server Names by **@danny-avila** in [#7421](https://github.com/danny-avila/LibreChat/pull/7421)
-- 📊 feat: Improve Helm Chart by **@hofq** in [#3638](https://github.com/danny-avila/LibreChat/pull/3638)
-- 🦾 feat: Claude-4 Support by **@danny-avila** in [#7509](https://github.com/danny-avila/LibreChat/pull/7509)
-- 🪨 feat: Bedrock Support for Claude-4 Reasoning by **@danny-avila** in [#7517](https://github.com/danny-avila/LibreChat/pull/7517)
-
-### 🌍 Internationalization
-
-- 🌍 i18n: Add `Danish` and `Czech` and `Catalan` localization support by **@rubentalstra** in [#7373](https://github.com/danny-avila/LibreChat/pull/7373)
-- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7375](https://github.com/danny-avila/LibreChat/pull/7375)
-- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7468](https://github.com/danny-avila/LibreChat/pull/7468)
-
-### 🔧 Fixes
-
-- 💬 fix: update aria-label for accessibility in ConvoLink component by **@berry-13** in [#7320](https://github.com/danny-avila/LibreChat/pull/7320)
-- 🔑 fix: use `apiKey` instead of `openAIApiKey` in OpenAI-like Config by **@danny-avila** in [#7337](https://github.com/danny-avila/LibreChat/pull/7337)
-- 🔄 fix: update navigation logic in `useFocusChatEffect` to ensure correct search parameters are used by **@mawburn** in [#7340](https://github.com/danny-avila/LibreChat/pull/7340)
-- 🔄 fix: Improve MCP Connection Cleanup by **@danny-avila** in [#7400](https://github.com/danny-avila/LibreChat/pull/7400)
-- 🛡️ fix: Preset and Validation Logic for URL Query Params by **@danny-avila** in [#7407](https://github.com/danny-avila/LibreChat/pull/7407)
-- 🌘 fix: artifact of preview text is illegible in dark mode by **@nhtruong** in [#7405](https://github.com/danny-avila/LibreChat/pull/7405)
-- 🛡️ fix: Temporarily Remove CSP until Configurable by **@danny-avila** in [#7419](https://github.com/danny-avila/LibreChat/pull/7419)
-- 💽 fix: Exclude index page `/` from static cache settings by **@sbruel** in [#7382](https://github.com/danny-avila/LibreChat/pull/7382)
-
-### ⚙️ Other Changes
-
-- 📜 docs: CHANGELOG for release v0.7.8 by **@github-actions[bot]** in [#7290](https://github.com/danny-avila/LibreChat/pull/7290)
-- 📦 chore: Update API Package Dependencies by **@danny-avila** in [#7359](https://github.com/danny-avila/LibreChat/pull/7359)
-- 📜 docs: Unreleased Changelog by **@github-actions[bot]** in [#7321](https://github.com/danny-avila/LibreChat/pull/7321)
-- 📜 docs: Unreleased Changelog by **@github-actions[bot]** in [#7434](https://github.com/danny-avila/LibreChat/pull/7434)
-- 🛡️ chore: `multer` v2.0.0 for CVE-2025-47935 and CVE-2025-47944 by **@danny-avila** in [#7454](https://github.com/danny-avila/LibreChat/pull/7454)
-- 📂 refactor: Improve `FileAttachment` & File Form Deletion by **@danny-avila** in [#7471](https://github.com/danny-avila/LibreChat/pull/7471)
-- 📊 chore: Remove Old Helm Chart by **@hofq** in [#7512](https://github.com/danny-avila/LibreChat/pull/7512)
-- 🪖 chore: bump helm app version to v0.7.8 by **@austin-barrington** in [#7524](https://github.com/danny-avila/LibreChat/pull/7524)
-
-
-
----
-## [v0.7.8] -
-
-Changes from v0.7.8-rc1 to v0.7.8.
-
-### ✨ New Features
-
-- ✨ feat: Enhance form submission for touch screens by **@berry-13** in [#7198](https://github.com/danny-avila/LibreChat/pull/7198)
-- 🔍 feat: Additional Tavily API Tool Parameters by **@glowforge-opensource** in [#7232](https://github.com/danny-avila/LibreChat/pull/7232)
-- 🐋 feat: Add python to Dockerfile for increased MCP compatibility by **@technicalpickles** in [#7270](https://github.com/danny-avila/LibreChat/pull/7270)
-
-### 🔧 Fixes
-
-- 🔧 fix: Google Gemma Support & OpenAI Reasoning Instructions by **@danny-avila** in [#7196](https://github.com/danny-avila/LibreChat/pull/7196)
-- 🛠️ fix: Conversation Navigation State by **@danny-avila** in [#7210](https://github.com/danny-avila/LibreChat/pull/7210)
-- 🔄 fix: o-Series Model Regex for System Messages by **@danny-avila** in [#7245](https://github.com/danny-avila/LibreChat/pull/7245)
-- 🔖 fix: Custom Headers for Initial MCP SSE Connection by **@danny-avila** in [#7246](https://github.com/danny-avila/LibreChat/pull/7246)
-- 🛡️ fix: Deep Clone `MCPOptions` for User MCP Connections by **@danny-avila** in [#7247](https://github.com/danny-avila/LibreChat/pull/7247)
-- 🔄 fix: URL Param Race Condition and File Draft Persistence by **@danny-avila** in [#7257](https://github.com/danny-avila/LibreChat/pull/7257)
-- 🔄 fix: Assistants Endpoint & Minor Issues by **@danny-avila** in [#7274](https://github.com/danny-avila/LibreChat/pull/7274)
-- 🔄 fix: Ollama Think Tag Edge Case with Tools by **@danny-avila** in [#7275](https://github.com/danny-avila/LibreChat/pull/7275)
-
-### ⚙️ Other Changes
-
-- 📜 docs: CHANGELOG for release v0.7.8-rc1 by **@github-actions[bot]** in [#7153](https://github.com/danny-avila/LibreChat/pull/7153)
-- 🔄 refactor: Artifact Visibility Management by **@danny-avila** in [#7181](https://github.com/danny-avila/LibreChat/pull/7181)
-- 📦 chore: Bump Package Security by **@danny-avila** in [#7183](https://github.com/danny-avila/LibreChat/pull/7183)
-- 🌿 refactor: Unmount Fork Popover on Hide for Better Performance by **@danny-avila** in [#7189](https://github.com/danny-avila/LibreChat/pull/7189)
-- 🧰 chore: ESLint configuration to enforce Prettier formatting rules by **@mawburn** in [#7186](https://github.com/danny-avila/LibreChat/pull/7186)
-- 🎨 style: Improve KaTeX Rendering for LaTeX Equations by **@andresgit** in [#7223](https://github.com/danny-avila/LibreChat/pull/7223)
-- 📝 docs: Update `.env.example` Google models by **@marlonka** in [#7254](https://github.com/danny-avila/LibreChat/pull/7254)
-- 💬 refactor: MCP Chat Visibility Option, Google Rates, Remove OpenAPI Plugins by **@danny-avila** in [#7286](https://github.com/danny-avila/LibreChat/pull/7286)
-- 📜 docs: Unreleased Changelog by **@github-actions[bot]** in [#7214](https://github.com/danny-avila/LibreChat/pull/7214)
-
-
-
-[See full release details][release-v0.7.8]
-
-[release-v0.7.8]: https://github.com/danny-avila/LibreChat/releases/tag/v0.7.8
-
----
-## [v0.7.8-rc1] -
-
-Changes from v0.7.7 to v0.7.8-rc1.
-
-### ✨ New Features
-
-- 🔍 feat: Mistral OCR API / Upload Files as Text by **@danny-avila** in [#6274](https://github.com/danny-avila/LibreChat/pull/6274)
-- 🤖 feat: Support OpenAI Web Search models by **@danny-avila** in [#6313](https://github.com/danny-avila/LibreChat/pull/6313)
-- 🔗 feat: Agent Chain (Mixture-of-Agents) by **@danny-avila** in [#6374](https://github.com/danny-avila/LibreChat/pull/6374)
-- ⌛ feat: `initTimeout` for Slow Starting MCP Servers by **@perweij** in [#6383](https://github.com/danny-avila/LibreChat/pull/6383)
-- 🚀 feat: `S3` Integration for File handling and Image uploads by **@rubentalstra** in [#6142](https://github.com/danny-avila/LibreChat/pull/6142)
-- 🔒feat: Enable OpenID Auto-Redirect by **@leondape** in [#6066](https://github.com/danny-avila/LibreChat/pull/6066)
-- 🚀 feat: Integrate `Azure Blob Storage` for file handling and image uploads by **@rubentalstra** in [#6153](https://github.com/danny-avila/LibreChat/pull/6153)
-- 🚀 feat: Add support for custom `AWS` endpoint in `S3` by **@rubentalstra** in [#6431](https://github.com/danny-avila/LibreChat/pull/6431)
-- 🚀 feat: Add support for LDAP STARTTLS in LDAP authentication by **@rubentalstra** in [#6438](https://github.com/danny-avila/LibreChat/pull/6438)
-- 🚀 feat: Refactor schema exports and update package version to 0.0.4 by **@rubentalstra** in [#6455](https://github.com/danny-avila/LibreChat/pull/6455)
-- 🔼 feat: Add Auto Submit For URL Query Params by **@mjaverto** in [#6440](https://github.com/danny-avila/LibreChat/pull/6440)
-- 🛠 feat: Enhance Redis Integration, Rate Limiters & Log Headers by **@danny-avila** in [#6462](https://github.com/danny-avila/LibreChat/pull/6462)
-- 💵 feat: Add Automatic Balance Refill by **@rubentalstra** in [#6452](https://github.com/danny-avila/LibreChat/pull/6452)
-- 🗣️ feat: add support for gpt-4o-transcribe models by **@berry-13** in [#6483](https://github.com/danny-avila/LibreChat/pull/6483)
-- 🎨 feat: UI Refresh for Enhanced UX by **@berry-13** in [#6346](https://github.com/danny-avila/LibreChat/pull/6346)
-- 🌍 feat: Add support for Hungarian language localization by **@rubentalstra** in [#6508](https://github.com/danny-avila/LibreChat/pull/6508)
-- 🚀 feat: Add Gemini 2.5 Token/Context Values, Increase Max Possible Output to 64k by **@danny-avila** in [#6563](https://github.com/danny-avila/LibreChat/pull/6563)
-- 🚀 feat: Enhance MCP Connections For Multi-User Support by **@danny-avila** in [#6610](https://github.com/danny-avila/LibreChat/pull/6610)
-- 🚀 feat: Enhance S3 URL Expiry with Refresh; fix: S3 File Deletion by **@danny-avila** in [#6647](https://github.com/danny-avila/LibreChat/pull/6647)
-- 🚀 feat: enhance UI components and refactor settings by **@berry-13** in [#6625](https://github.com/danny-avila/LibreChat/pull/6625)
-- 💬 feat: move TemporaryChat to the Header by **@berry-13** in [#6646](https://github.com/danny-avila/LibreChat/pull/6646)
-- 🚀 feat: Use Model Specs + Specific Endpoints, Limit Providers for Agents by **@danny-avila** in [#6650](https://github.com/danny-avila/LibreChat/pull/6650)
-- 🪙 feat: Sync Balance Config on Login by **@danny-avila** in [#6671](https://github.com/danny-avila/LibreChat/pull/6671)
-- 🔦 feat: MCP Support for Non-Agent Endpoints by **@danny-avila** in [#6775](https://github.com/danny-avila/LibreChat/pull/6775)
-- 🗃️ feat: Code Interpreter File Persistence between Sessions by **@danny-avila** in [#6790](https://github.com/danny-avila/LibreChat/pull/6790)
-- 🖥️ feat: Code Interpreter API for Non-Agent Endpoints by **@danny-avila** in [#6803](https://github.com/danny-avila/LibreChat/pull/6803)
-- ⚡ feat: Self-hosted Artifacts Static Bundler URL by **@danny-avila** in [#6827](https://github.com/danny-avila/LibreChat/pull/6827)
-- 🐳 feat: Add Jemalloc and UV to Docker Builds by **@danny-avila** in [#6836](https://github.com/danny-avila/LibreChat/pull/6836)
-- 🤖 feat: GPT-4.1 by **@danny-avila** in [#6880](https://github.com/danny-avila/LibreChat/pull/6880)
-- 👋 feat: remove Edge TTS by **@berry-13** in [#6885](https://github.com/danny-avila/LibreChat/pull/6885)
-- feat: nav optimization by **@berry-13** in [#5785](https://github.com/danny-avila/LibreChat/pull/5785)
-- 🗺️ feat: Add Parameter Location Mapping for OpenAPI actions by **@peeeteeer** in [#6858](https://github.com/danny-avila/LibreChat/pull/6858)
-- 🤖 feat: Support `o4-mini` and `o3` Models by **@danny-avila** in [#6928](https://github.com/danny-avila/LibreChat/pull/6928)
-- 🎨 feat: OpenAI Image Tools (GPT-Image-1) by **@danny-avila** in [#7079](https://github.com/danny-avila/LibreChat/pull/7079)
-- 🗓️ feat: Add Special Variables for Prompts & Agents, Prompt UI Improvements by **@danny-avila** in [#7123](https://github.com/danny-avila/LibreChat/pull/7123)
-
-### 🌍 Internationalization
-
-- 🌍 i18n: Add Thai Language Support and Update Translations by **@rubentalstra** in [#6219](https://github.com/danny-avila/LibreChat/pull/6219)
-- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6220](https://github.com/danny-avila/LibreChat/pull/6220)
-- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6240](https://github.com/danny-avila/LibreChat/pull/6240)
-- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6241](https://github.com/danny-avila/LibreChat/pull/6241)
-- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6277](https://github.com/danny-avila/LibreChat/pull/6277)
-- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6414](https://github.com/danny-avila/LibreChat/pull/6414)
-- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6505](https://github.com/danny-avila/LibreChat/pull/6505)
-- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6530](https://github.com/danny-avila/LibreChat/pull/6530)
-- 🌍 i18n: Add Persian Localization Support by **@rubentalstra** in [#6669](https://github.com/danny-avila/LibreChat/pull/6669)
-- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6667](https://github.com/danny-avila/LibreChat/pull/6667)
-- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7126](https://github.com/danny-avila/LibreChat/pull/7126)
-- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7148](https://github.com/danny-avila/LibreChat/pull/7148)
-
-### 👐 Accessibility
-
-- 🎨 a11y: Update Model Spec Description Text by **@berry-13** in [#6294](https://github.com/danny-avila/LibreChat/pull/6294)
-- 🗑️ a11y: Add Accessible Name to Button for File Attachment Removal by **@kangabell** in [#6709](https://github.com/danny-avila/LibreChat/pull/6709)
-- ⌨️ a11y: enhance accessibility & visual consistency by **@berry-13** in [#6866](https://github.com/danny-avila/LibreChat/pull/6866)
-- 🙌 a11y: Searchbar/Conversations List Focus by **@danny-avila** in [#7096](https://github.com/danny-avila/LibreChat/pull/7096)
-- 👐 a11y: Improve Fork and SplitText Accessibility by **@danny-avila** in [#7147](https://github.com/danny-avila/LibreChat/pull/7147)
-
-### 🔧 Fixes
-
-- 🐛 fix: Avatar Type Definitions in Agent/Assistant Schemas by **@danny-avila** in [#6235](https://github.com/danny-avila/LibreChat/pull/6235)
-- 🔧 fix: MeiliSearch Field Error and Patch Incorrect Import by #6210 by **@rubentalstra** in [#6245](https://github.com/danny-avila/LibreChat/pull/6245)
-- 🔏 fix: Enhance Two-Factor Authentication by **@rubentalstra** in [#6247](https://github.com/danny-avila/LibreChat/pull/6247)
-- 🐛 fix: Await saveMessage in abortMiddleware to ensure proper execution by **@sh4shii** in [#6248](https://github.com/danny-avila/LibreChat/pull/6248)
-- 🔧 fix: Axios Proxy Usage And Bump `mongoose` by **@danny-avila** in [#6298](https://github.com/danny-avila/LibreChat/pull/6298)
-- 🔧 fix: comment out MCP servers to resolve service run issues by **@KunalScriptz** in [#6316](https://github.com/danny-avila/LibreChat/pull/6316)
-- 🔧 fix: Update Token Calculations and Mapping, MCP `env` Initialization by **@danny-avila** in [#6406](https://github.com/danny-avila/LibreChat/pull/6406)
-- 🐞 fix: Agent "Resend" Message Attachments + Source Icon Styling by **@danny-avila** in [#6408](https://github.com/danny-avila/LibreChat/pull/6408)
-- 🐛 fix: Prevent Crash on Duplicate Message ID by **@Odrec** in [#6392](https://github.com/danny-avila/LibreChat/pull/6392)
-- 🔐 fix: Invalid Key Length in 2FA Encryption by **@rubentalstra** in [#6432](https://github.com/danny-avila/LibreChat/pull/6432)
-- 🏗️ fix: Fix Agents Token Spend Race Conditions, Expand Test Coverage by **@danny-avila** in [#6480](https://github.com/danny-avila/LibreChat/pull/6480)
-- 🔃 fix: Draft Clearing, Claude Titles, Remove Default Vision Max Tokens by **@danny-avila** in [#6501](https://github.com/danny-avila/LibreChat/pull/6501)
-- 🔧 fix: Update username reference to use user.name in greeting display by **@rubentalstra** in [#6534](https://github.com/danny-avila/LibreChat/pull/6534)
-- 🔧 fix: S3 Download Stream with Key Extraction and Blob Storage Encoding for Vision by **@danny-avila** in [#6557](https://github.com/danny-avila/LibreChat/pull/6557)
-- 🔧 fix: Mistral type strictness for `usage` & update token values/windows by **@danny-avila** in [#6562](https://github.com/danny-avila/LibreChat/pull/6562)
-- 🔧 fix: Consolidate Text Parsing and TTS Edge Initialization by **@danny-avila** in [#6582](https://github.com/danny-avila/LibreChat/pull/6582)
-- 🔧 fix: Ensure continuation in image processing on base64 encoding from Blob Storage by **@danny-avila** in [#6619](https://github.com/danny-avila/LibreChat/pull/6619)
-- ✉️ fix: Fallback For User Name In Email Templates by **@danny-avila** in [#6620](https://github.com/danny-avila/LibreChat/pull/6620)
-- 🔧 fix: Azure Blob Integration and File Source References by **@rubentalstra** in [#6575](https://github.com/danny-avila/LibreChat/pull/6575)
-- 🐛 fix: Safeguard against undefined addedEndpoints by **@wipash** in [#6654](https://github.com/danny-avila/LibreChat/pull/6654)
-- 🤖 fix: Gemini 2.5 Vision Support by **@danny-avila** in [#6663](https://github.com/danny-avila/LibreChat/pull/6663)
-- 🔄 fix: Avatar & Error Handling Enhancements by **@danny-avila** in [#6687](https://github.com/danny-avila/LibreChat/pull/6687)
-- 🔧 fix: Chat Middleware, Zod Conversion, Auto-Save and S3 URL Refresh by **@danny-avila** in [#6720](https://github.com/danny-avila/LibreChat/pull/6720)
-- 🔧 fix: Agent Capability Checks & DocumentDB Compatibility for Agent Resource Removal by **@danny-avila** in [#6726](https://github.com/danny-avila/LibreChat/pull/6726)
-- 🔄 fix: Improve audio MIME type detection and handling by **@berry-13** in [#6707](https://github.com/danny-avila/LibreChat/pull/6707)
-- 🪺 fix: Update Role Handling due to New Schema Shape by **@danny-avila** in [#6774](https://github.com/danny-avila/LibreChat/pull/6774)
-- 🗨️ fix: Show ModelSpec Greeting by **@berry-13** in [#6770](https://github.com/danny-avila/LibreChat/pull/6770)
-- 🔧 fix: Keyv and Proxy Issues, and More Memory Optimizations by **@danny-avila** in [#6867](https://github.com/danny-avila/LibreChat/pull/6867)
-- ✨ fix: Implement dynamic text sizing for greeting and name display by **@berry-13** in [#6833](https://github.com/danny-avila/LibreChat/pull/6833)
-- 📝 fix: Mistral OCR Image Support and Azure Agent Titles by **@danny-avila** in [#6901](https://github.com/danny-avila/LibreChat/pull/6901)
-- 📢 fix: Invalid `engineTTS` and Conversation State on Navigation by **@berry-13** in [#6904](https://github.com/danny-avila/LibreChat/pull/6904)
-- 🛠️ fix: Improve Accessibility and Display of Conversation Menu by **@danny-avila** in [#6913](https://github.com/danny-avila/LibreChat/pull/6913)
-- 🔧 fix: Agent Resource Form, Convo Menu Style, Ensure Draft Clears on Submission by **@danny-avila** in [#6925](https://github.com/danny-avila/LibreChat/pull/6925)
-- 🔀 fix: MCP Improvements, Auto-Save Drafts, Artifact Markup by **@danny-avila** in [#7040](https://github.com/danny-avila/LibreChat/pull/7040)
-- 🐋 fix: Improve Deepseek Compatbility by **@danny-avila** in [#7132](https://github.com/danny-avila/LibreChat/pull/7132)
-- 🐙 fix: Add Redis Ping Interval to Prevent Connection Drops by **@peeeteeer** in [#7127](https://github.com/danny-avila/LibreChat/pull/7127)
-
-### ⚙️ Other Changes
-
-- 📦 refactor: Move DB Models to `@librechat/data-schemas` by **@rubentalstra** in [#6210](https://github.com/danny-avila/LibreChat/pull/6210)
-- 📦 chore: Patch `axios` to address CVE-2025-27152 by **@danny-avila** in [#6222](https://github.com/danny-avila/LibreChat/pull/6222)
-- ⚠️ refactor: Use Error Content Part Instead Of Throwing Error for Agents by **@danny-avila** in [#6262](https://github.com/danny-avila/LibreChat/pull/6262)
-- 🏃♂️ refactor: Improve Agent Run Context & Misc. Changes by **@danny-avila** in [#6448](https://github.com/danny-avila/LibreChat/pull/6448)
-- 📝 docs: librechat.example.yaml by **@ineiti** in [#6442](https://github.com/danny-avila/LibreChat/pull/6442)
-- 🏃♂️ refactor: More Agent Context Improvements during Run by **@danny-avila** in [#6477](https://github.com/danny-avila/LibreChat/pull/6477)
-- 🔃 refactor: Allow streaming for `o1` models by **@danny-avila** in [#6509](https://github.com/danny-avila/LibreChat/pull/6509)
-- 🔧 chore: `Vite` Plugin Upgrades & Config Optimizations by **@rubentalstra** in [#6547](https://github.com/danny-avila/LibreChat/pull/6547)
-- 🔧 refactor: Consolidate Logging, Model Selection & Actions Optimizations, Minor Fixes by **@danny-avila** in [#6553](https://github.com/danny-avila/LibreChat/pull/6553)
-- 🎨 style: Address Minor UI Refresh Issues by **@berry-13** in [#6552](https://github.com/danny-avila/LibreChat/pull/6552)
-- 🔧 refactor: Enhance Model & Endpoint Configurations with Global Indicators 🌍 by **@berry-13** in [#6578](https://github.com/danny-avila/LibreChat/pull/6578)
-- 💬 style: Chat UI, Greeting, and Message adjustments by **@berry-13** in [#6612](https://github.com/danny-avila/LibreChat/pull/6612)
-- ⚡ refactor: DocumentDB Compatibility for Balance Updates by **@danny-avila** in [#6673](https://github.com/danny-avila/LibreChat/pull/6673)
-- 🧹 chore: Update ESLint rules for React hooks by **@rubentalstra** in [#6685](https://github.com/danny-avila/LibreChat/pull/6685)
-- 🪙 chore: Update Gemini Pricing by **@RedwindA** in [#6731](https://github.com/danny-avila/LibreChat/pull/6731)
-- 🪺 refactor: Nest Permission fields for Roles by **@rubentalstra** in [#6487](https://github.com/danny-avila/LibreChat/pull/6487)
-- 📦 chore: Update `caniuse-lite` dependency to version 1.0.30001706 by **@rubentalstra** in [#6482](https://github.com/danny-avila/LibreChat/pull/6482)
-- ⚙️ refactor: OAuth Flow Signal, Type Safety, Tool Progress & Updated Packages by **@danny-avila** in [#6752](https://github.com/danny-avila/LibreChat/pull/6752)
-- 📦 chore: bump vite from 6.2.3 to 6.2.5 by **@dependabot[bot]** in [#6745](https://github.com/danny-avila/LibreChat/pull/6745)
-- 💾 chore: Enhance Local Storage Handling and Update MCP SDK by **@danny-avila** in [#6809](https://github.com/danny-avila/LibreChat/pull/6809)
-- 🤖 refactor: Improve Agents Memory Usage, Bump Keyv, Grok 3 by **@danny-avila** in [#6850](https://github.com/danny-avila/LibreChat/pull/6850)
-- 💾 refactor: Enhance Memory In Image Encodings & Client Disposal by **@danny-avila** in [#6852](https://github.com/danny-avila/LibreChat/pull/6852)
-- 🔁 refactor: Token Event Handler and Standardize `maxTokens` Key by **@danny-avila** in [#6886](https://github.com/danny-avila/LibreChat/pull/6886)
-- 🔍 refactor: Search & Message Retrieval by **@berry-13** in [#6903](https://github.com/danny-avila/LibreChat/pull/6903)
-- 🎨 style: standardize dropdown styling & fix z-Index layering by **@berry-13** in [#6939](https://github.com/danny-avila/LibreChat/pull/6939)
-- 📙 docs: CONTRIBUTING.md by **@dblock** in [#6831](https://github.com/danny-avila/LibreChat/pull/6831)
-- 🧭 refactor: Modernize Nav/Header by **@danny-avila** in [#7094](https://github.com/danny-avila/LibreChat/pull/7094)
-- 🪶 refactor: Chat Input Focus for Conversation Navigations & ChatForm Optimizations by **@danny-avila** in [#7100](https://github.com/danny-avila/LibreChat/pull/7100)
-- 🔃 refactor: Streamline Navigation, Message Loading UX by **@danny-avila** in [#7118](https://github.com/danny-avila/LibreChat/pull/7118)
-- 📜 docs: Unreleased changelog by **@github-actions[bot]** in [#6265](https://github.com/danny-avila/LibreChat/pull/6265)
-
-
-
-[See full release details][release-v0.7.8-rc1]
-
-[release-v0.7.8-rc1]: https://github.com/danny-avila/LibreChat/releases/tag/v0.7.8-rc1
-
----
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 120000
index 0000000000..47dc3e3d86
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 9482c2fd3a..bbff8133da 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-# v0.8.1-rc2
+# v0.8.3
# Base node image
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
# 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
+# Set configurable max-old-space-size with default
+ARG NODE_MAX_OLD_SPACE_SIZE=6144
+
RUN mkdir -p /app && chown node:node /app
WORKDIR /app
@@ -30,7 +33,7 @@ RUN \
# Allow mounting of these files, which have no default
touch .env ; \
# Create directories for the volumes to inherit the correct permissions
- mkdir -p /app/client/public/images /app/api/logs /app/uploads ; \
+ mkdir -p /app/client/public/images /app/logs /app/uploads ; \
npm config set fetch-retry-maxtimeout 600000 ; \
npm config set fetch-retries 5 ; \
npm config set fetch-retry-mintimeout 15000 ; \
@@ -39,8 +42,8 @@ RUN \
COPY --chown=node:node . .
RUN \
- # React client build
- NODE_OPTIONS="--max-old-space-size=2048" npm run frontend; \
+ # React client build with configurable memory
+ NODE_OPTIONS="--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE}" npm run frontend; \
npm prune --production; \
npm cache clean --force
diff --git a/Dockerfile.multi b/Dockerfile.multi
index 21c073b750..53810b5f0a 100644
--- a/Dockerfile.multi
+++ b/Dockerfile.multi
@@ -1,5 +1,8 @@
# Dockerfile.multi
-# v0.8.1-rc2
+# v0.8.3
+
+# Set configurable max-old-space-size with default
+ARG NODE_MAX_OLD_SPACE_SIZE=6144
# Base for all builds
FROM node:20-alpine AS base-min
@@ -7,6 +10,7 @@ FROM node:20-alpine AS base-min
RUN apk add --no-cache jemalloc
# Set environment variable to use jemalloc
ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2
+
WORKDIR /app
RUN apk --no-cache add curl
RUN npm config set fetch-retry-maxtimeout 600000 && \
@@ -59,7 +63,8 @@ COPY client ./
COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist
COPY --from=client-package-build /app/packages/client/dist /app/packages/client/dist
COPY --from=client-package-build /app/packages/client/src /app/packages/client/src
-ENV NODE_OPTIONS="--max-old-space-size=2048"
+ARG NODE_MAX_OLD_SPACE_SIZE
+ENV NODE_OPTIONS="--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE}"
RUN npm run build
# API setup (including client dist)
@@ -79,4 +84,4 @@ COPY --from=client-build /app/client/dist ./client/dist
WORKDIR /app/api
EXPOSE 3080
ENV HOST=0.0.0.0
-CMD ["node", "server/index.js"]
\ No newline at end of file
+CMD ["node", "server/index.js"]
diff --git a/README.md b/README.md
index a96e47f70f..e82b3ebc2c 100644
--- a/README.md
+++ b/README.md
@@ -27,8 +27,8 @@
-
-
+
+
@@ -109,6 +109,11 @@
- 🎨 **Customizable Interface**:
- Customizable Dropdown & Interface that adapts to both power users and newcomers
+- 🌊 **[Resumable Streams](https://www.librechat.ai/docs/features/resumable_streams)**:
+ - Never lose a response: AI responses automatically reconnect and resume if your connection drops
+ - Multi-Tab & Multi-Device Sync: Open the same chat in multiple tabs or pick up on another device
+ - Production-Ready: Works from single-server setups to horizontally scaled deployments with Redis
+
- 🗣️ **Speech & Audio**:
- Chat hands-free with Speech-to-Text and Text-to-Speech
- Automatically send and play Audio
@@ -137,13 +142,11 @@
## 🪶 All-In-One AI Conversations with LibreChat
-LibreChat brings together the future of assistant AIs with the revolutionary technology of OpenAI's ChatGPT. Celebrating the original styling, LibreChat gives you the ability to integrate multiple AI models. It also integrates and enhances original client features such as conversation and message search, prompt templates and plugins.
+LibreChat is a self-hosted AI chat platform that unifies all major AI providers in a single, privacy-focused interface.
-With LibreChat, you no longer need to opt for ChatGPT Plus and can instead use free or pay-per-call APIs. We welcome contributions, cloning, and forking to enhance the capabilities of this advanced chatbot platform.
+Beyond chat, LibreChat provides AI Agents, Model Context Protocol (MCP) support, Artifacts, Code Interpreter, custom actions, conversation search, and enterprise-ready multi-user authentication.
-[](https://www.youtube.com/watch?v=ilfwGQtJNlI)
-
-Click on the thumbnail to open the video☝️
+Open source, actively developed, and built for anyone who values control over their AI infrastructure.
---
diff --git a/api/app/clients/AnthropicClient.js b/api/app/clients/AnthropicClient.js
deleted file mode 100644
index 16a79278f1..0000000000
--- a/api/app/clients/AnthropicClient.js
+++ /dev/null
@@ -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} 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} 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}
- */
- 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} 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} 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 = `
- ${truncateText(text)}
-
-
- ${JSON.stringify(truncateText(responseText))}
- `;
-
- 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 = `
- ${convo}
-
-
- 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', ''],
- 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;
diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js
index c0d9169b51..8f931f8a5e 100644
--- a/api/app/clients/BaseClient.js
+++ b/api/app/clients/BaseClient.js
@@ -2,7 +2,9 @@ const crypto = require('crypto');
const fetch = require('node-fetch');
const { logger } = require('@librechat/data-schemas');
const {
+ countTokens,
getBalanceConfig,
+ buildMessageFiles,
extractFileContext,
encodeAndFormatAudios,
encodeAndFormatVideos,
@@ -17,14 +19,21 @@ const {
EModelEndpoint,
isParamEndpoint,
isAgentsEndpoint,
+ isEphemeralAgentId,
supportsBalanceCheck,
+ isBedrockDocumentType,
} = 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 { checkBalance } = require('~/models/balanceMethods');
const { truncateToolCallOutputs } = require('./prompts');
-const countTokens = require('~/server/utils/countTokens');
-const { getFiles } = require('~/models/File');
const TextStream = require('./TextStream');
class BaseClient {
@@ -115,7 +124,9 @@ class BaseClient {
* @returns {number}
*/
getTokenCountForResponse(responseMessage) {
- logger.debug('[BaseClient] `recordTokenUsage` not implemented.', responseMessage);
+ logger.debug('[BaseClient] `recordTokenUsage` not implemented.', {
+ messageId: responseMessage?.messageId,
+ });
}
/**
@@ -126,12 +137,14 @@ class BaseClient {
* @param {AppConfig['balance']} [balance]
* @param {number} promptTokens
* @param {number} completionTokens
+ * @param {string} [messageId]
* @returns {Promise}
*/
- async recordTokenUsage({ model, balance, promptTokens, completionTokens }) {
+ async recordTokenUsage({ model, balance, promptTokens, completionTokens, messageId }) {
logger.debug('[BaseClient] `recordTokenUsage` not implemented.', {
model,
balance,
+ messageId,
promptTokens,
completionTokens,
});
@@ -652,16 +665,27 @@ class BaseClient {
);
if (tokenCountMap) {
- logger.debug('[BaseClient] tokenCountMap', tokenCountMap);
if (tokenCountMap[userMessage.messageId]) {
userMessage.tokenCount = tokenCountMap[userMessage.messageId];
- logger.debug('[BaseClient] userMessage', userMessage);
+ logger.debug('[BaseClient] userMessage', {
+ messageId: userMessage.messageId,
+ tokenCount: userMessage.tokenCount,
+ conversationId: userMessage.conversationId,
+ });
}
this.handleTokenCountMap(tokenCountMap);
}
if (!isEdited && !this.skipSaveUserMessage) {
+ const reqFiles = this.options.req?.body?.files;
+ if (reqFiles && Array.isArray(this.options.attachments)) {
+ const files = buildMessageFiles(reqFiles, this.options.attachments);
+ if (files.length > 0) {
+ userMessage.files = files;
+ }
+ delete userMessage.image_urls;
+ }
userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user);
this.savedMessageIds.add(userMessage.messageId);
if (typeof opts?.getReqData === 'function') {
@@ -708,7 +732,7 @@ class BaseClient {
iconURL: this.options.iconURL,
endpoint: this.options.endpoint,
...(this.metadata ?? {}),
- metadata,
+ metadata: Object.keys(metadata ?? {}).length > 0 ? metadata : undefined,
};
if (typeof completion === 'string') {
@@ -773,9 +797,18 @@ class BaseClient {
promptTokens,
completionTokens,
balance: balanceConfig,
- model: responseMessage.model,
+ /** Note: When using agents, responseMessage.model is the agent ID, not the model */
+ model: this.model,
+ messageId: this.responseMessageId,
});
}
+
+ logger.debug('[BaseClient] Response token usage', {
+ messageId: responseMessage.messageId,
+ model: responseMessage.model,
+ promptTokens,
+ completionTokens,
+ });
}
if (userMessagePromise) {
@@ -931,6 +964,7 @@ class BaseClient {
throw new Error('User mismatch.');
}
+ const hasAddedConvo = this.options?.req?.body?.addedConvo != null;
const savedMessage = await saveMessage(
this.options?.req,
{
@@ -938,6 +972,7 @@ class BaseClient {
endpoint: this.options.endpoint,
unfinished: false,
user,
+ ...(hasAddedConvo && { addedConvo: true }),
},
{ context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveMessage' },
);
@@ -960,6 +995,13 @@ class BaseClient {
const unsetFields = {};
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) {
this.fetchedConvo = true;
for (const key in existingConvo) {
@@ -1011,7 +1053,8 @@ class BaseClient {
* @param {Object} options - The options for the function.
* @param {TMessage[]} options.messages - An array of message objects. Each object should have either an 'id' or 'messageId' property, and may have a 'parentMessageId' property.
* @param {string} options.parentMessageId - The ID of the parent message to start the traversal from.
- * @param {Function} [options.mapMethod] - An optional function to map over the ordered messages. If provided, it will be applied to each message in the resulting array.
+ * @param {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.
* @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 +1062,7 @@ class BaseClient {
messages,
parentMessageId,
mapMethod = null,
+ mapCondition = null,
summary = false,
}) {
if (!messages || messages.length === 0) {
@@ -1053,7 +1097,9 @@ class BaseClient {
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) {
break;
@@ -1064,11 +1110,6 @@ class BaseClient {
}
orderedMessages.reverse();
-
- if (mapMethod) {
- return orderedMessages.map(mapMethod);
- }
-
return orderedMessages;
}
@@ -1285,6 +1326,9 @@ class BaseClient {
const allFiles = [];
+ const provider = this.options.agent?.provider ?? this.options.endpoint;
+ const isBedrock = provider === EModelEndpoint.bedrock;
+
for (const file of attachments) {
/** @type {FileSources} */
const source = file.source ?? FileSources.local;
@@ -1302,6 +1346,9 @@ class BaseClient {
} else if (file.type === 'application/pdf') {
categorizedAttachments.documents.push(file);
allFiles.push(file);
+ } else if (isBedrock && isBedrockDocumentType(file.type)) {
+ categorizedAttachments.documents.push(file);
+ allFiles.push(file);
} else if (file.type.startsWith('video/')) {
categorizedAttachments.videos.push(file);
allFiles.push(file);
diff --git a/api/app/clients/GoogleClient.js b/api/app/clients/GoogleClient.js
deleted file mode 100644
index 760889df8c..0000000000
--- a/api/app/clients/GoogleClient.js
+++ /dev/null
@@ -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}
- */
- 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} 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}
- */
- 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;
diff --git a/api/app/clients/OllamaClient.js b/api/app/clients/OllamaClient.js
index b8bdacf13e..d0dda519fe 100644
--- a/api/app/clients/OllamaClient.js
+++ b/api/app/clients/OllamaClient.js
@@ -2,10 +2,9 @@ const { z } = require('zod');
const axios = require('axios');
const { Ollama } = require('ollama');
const { sleep } = require('@librechat/agents');
-const { resolveHeaders } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { Constants } = require('librechat-data-provider');
-const { deriveBaseURL } = require('~/utils');
+const { resolveHeaders, deriveBaseURL } = require('@librechat/api');
const ollamaPayloadSchema = z.object({
mirostat: z.number().optional(),
diff --git a/api/app/clients/OpenAIClient.js b/api/app/clients/OpenAIClient.js
deleted file mode 100644
index f4c42351e3..0000000000
--- a/api/app/clients/OpenAIClient.js
+++ /dev/null
@@ -1,1207 +0,0 @@
-const { logger } = require('@librechat/data-schemas');
-const { HttpsProxyAgent } = require('https-proxy-agent');
-const { sleep, SplitStreamHandler, CustomOpenAIClient: OpenAI } = require('@librechat/agents');
-const {
- isEnabled,
- Tokenizer,
- createFetch,
- resolveHeaders,
- constructAzureURL,
- getModelMaxTokens,
- genAzureChatCompletion,
- getModelMaxOutputTokens,
- createStreamEventHandlers,
-} = require('@librechat/api');
-const {
- Constants,
- ImageDetail,
- ContentTypes,
- parseTextParts,
- EModelEndpoint,
- KnownEndpoints,
- openAISettings,
- ImageDetailCost,
- getResponseSender,
- validateVisionModel,
- mapModelToAzureConfig,
-} = require('librechat-data-provider');
-const { encodeAndFormat } = require('~/server/services/Files/images/encode');
-const { formatMessage, createContextHandlers } = require('./prompts');
-const { spendTokens } = require('~/models/spendTokens');
-const { addSpaceIfNeeded } = require('~/server/utils');
-const { handleOpenAIErrors } = require('./tools/util');
-const { OllamaClient } = require('./OllamaClient');
-const { extractBaseURL } = require('~/utils');
-const BaseClient = require('./BaseClient');
-
-class OpenAIClient extends BaseClient {
- constructor(apiKey, options = {}) {
- super(apiKey, options);
- this.contextStrategy = options.contextStrategy
- ? options.contextStrategy.toLowerCase()
- : 'discard';
- this.shouldSummarize = this.contextStrategy === 'summarize';
- /** @type {AzureOptions} */
- this.azure = options.azure || false;
- this.setOptions(options);
- this.metadata = {};
-
- /** @type {string | undefined} - The API Completions URL */
- this.completionsUrl;
-
- /** @type {OpenAIUsageMetadata | undefined} */
- this.usage;
- /** @type {boolean|undefined} */
- this.isOmni;
- /** @type {SplitStreamHandler | undefined} */
- this.streamHandler;
- }
-
- // TODO: PluginsClient calls this 3x, unneeded
- setOptions(options) {
- if (this.options && !this.options.replaceOptions) {
- this.options.modelOptions = {
- ...this.options.modelOptions,
- ...options.modelOptions,
- };
- delete options.modelOptions;
- this.options = {
- ...this.options,
- ...options,
- };
- } else {
- this.options = options;
- }
-
- if (this.options.openaiApiKey) {
- this.apiKey = this.options.openaiApiKey;
- }
-
- this.modelOptions = Object.assign(
- {
- model: openAISettings.model.default,
- },
- this.modelOptions,
- this.options.modelOptions,
- );
-
- this.defaultVisionModel = this.options.visionModel ?? 'gpt-4-vision-preview';
- if (typeof this.options.attachments?.then === 'function') {
- this.options.attachments.then((attachments) => this.checkVisionRequest(attachments));
- } else {
- this.checkVisionRequest(this.options.attachments);
- }
-
- const omniPattern = /\b(o\d)\b/i;
- this.isOmni = omniPattern.test(this.modelOptions.model);
-
- const { OPENAI_FORCE_PROMPT } = process.env ?? {};
- const { reverseProxyUrl: reverseProxy } = this.options;
-
- if (
- !this.useOpenRouter &&
- ((reverseProxy && reverseProxy.includes(KnownEndpoints.openrouter)) ||
- (this.options.endpoint &&
- this.options.endpoint.toLowerCase().includes(KnownEndpoints.openrouter)))
- ) {
- this.useOpenRouter = true;
- }
-
- if (this.options.endpoint?.toLowerCase() === 'ollama') {
- this.isOllama = true;
- }
-
- this.FORCE_PROMPT =
- isEnabled(OPENAI_FORCE_PROMPT) ||
- (reverseProxy && reverseProxy.includes('completions') && !reverseProxy.includes('chat'));
-
- if (typeof this.options.forcePrompt === 'boolean') {
- this.FORCE_PROMPT = this.options.forcePrompt;
- }
-
- if (this.azure && process.env.AZURE_OPENAI_DEFAULT_MODEL) {
- this.azureEndpoint = genAzureChatCompletion(this.azure, this.modelOptions.model, this);
- this.modelOptions.model = process.env.AZURE_OPENAI_DEFAULT_MODEL;
- } else if (this.azure) {
- this.azureEndpoint = genAzureChatCompletion(this.azure, this.modelOptions.model, this);
- }
-
- const { model } = this.modelOptions;
-
- this.isChatCompletion =
- omniPattern.test(model) || model.includes('gpt') || this.useOpenRouter || !!reverseProxy;
- this.isChatGptModel = this.isChatCompletion;
- if (
- model.includes('text-davinci') ||
- model.includes('gpt-3.5-turbo-instruct') ||
- this.FORCE_PROMPT
- ) {
- this.isChatCompletion = false;
- this.isChatGptModel = false;
- }
- const { isChatGptModel } = this;
- this.isUnofficialChatGptModel =
- model.startsWith('text-chat') || model.startsWith('text-davinci-002-render');
-
- this.maxContextTokens =
- this.options.maxContextTokens ??
- getModelMaxTokens(
- model,
- this.options.endpointType ?? this.options.endpoint,
- this.options.endpointTokenConfig,
- ) ??
- 4095; // 1 less than maximum
-
- if (this.shouldSummarize) {
- this.maxContextTokens = Math.floor(this.maxContextTokens / 2);
- }
-
- if (this.options.debug) {
- logger.debug('[OpenAIClient] maxContextTokens', this.maxContextTokens);
- }
-
- this.maxResponseTokens =
- this.modelOptions.max_tokens ??
- getModelMaxOutputTokens(
- model,
- this.options.endpointType ?? this.options.endpoint,
- this.options.endpointTokenConfig,
- ) ??
- 1024;
- this.maxPromptTokens =
- this.options.maxPromptTokens || this.maxContextTokens - this.maxResponseTokens;
-
- if (this.maxPromptTokens + this.maxResponseTokens > this.maxContextTokens) {
- throw new Error(
- `maxPromptTokens + max_tokens (${this.maxPromptTokens} + ${this.maxResponseTokens} = ${
- this.maxPromptTokens + this.maxResponseTokens
- }) must be less than or equal to maxContextTokens (${this.maxContextTokens})`,
- );
- }
-
- this.sender =
- this.options.sender ??
- getResponseSender({
- model: this.modelOptions.model,
- endpoint: this.options.endpoint,
- endpointType: this.options.endpointType,
- modelDisplayLabel: this.options.modelDisplayLabel,
- chatGptLabel: this.options.chatGptLabel || this.options.modelLabel,
- });
-
- this.userLabel = this.options.userLabel || 'User';
- this.chatGptLabel = this.options.chatGptLabel || 'Assistant';
-
- this.setupTokens();
-
- if (reverseProxy) {
- this.completionsUrl = reverseProxy;
- this.langchainProxy = extractBaseURL(reverseProxy);
- } else if (isChatGptModel) {
- this.completionsUrl = 'https://api.openai.com/v1/chat/completions';
- } else {
- this.completionsUrl = 'https://api.openai.com/v1/completions';
- }
-
- if (this.azureEndpoint) {
- this.completionsUrl = this.azureEndpoint;
- }
-
- if (this.azureEndpoint && this.options.debug) {
- logger.debug('Using Azure endpoint');
- }
-
- return this;
- }
-
- /**
- *
- * 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) {
- if (!attachments) {
- return;
- }
-
- const availableModels = this.options.modelsConfig?.[this.options.endpoint];
- if (!availableModels) {
- return;
- }
-
- let visionRequestDetected = false;
- for (const file of attachments) {
- if (file?.type?.includes('image')) {
- visionRequestDetected = true;
- break;
- }
- }
- if (!visionRequestDetected) {
- return;
- }
-
- this.isVisionModel = validateVisionModel({ model: this.modelOptions.model, availableModels });
- if (this.isVisionModel) {
- delete this.modelOptions.stop;
- return;
- }
-
- for (const model of availableModels) {
- if (!validateVisionModel({ model, availableModels })) {
- continue;
- }
- this.modelOptions.model = model;
- this.isVisionModel = true;
- delete this.modelOptions.stop;
- return;
- }
-
- if (!availableModels.includes(this.defaultVisionModel)) {
- return;
- }
- if (!validateVisionModel({ model: this.defaultVisionModel, availableModels })) {
- return;
- }
-
- this.modelOptions.model = this.defaultVisionModel;
- this.isVisionModel = true;
- delete this.modelOptions.stop;
- }
-
- setupTokens() {
- if (this.isChatCompletion) {
- this.startToken = '||>';
- this.endToken = '';
- } else if (this.isUnofficialChatGptModel) {
- this.startToken = '<|im_start|>';
- this.endToken = '<|im_end|>';
- } else {
- this.startToken = '||>';
- this.endToken = '';
- }
- }
-
- getEncoding() {
- return this.modelOptions?.model && /gpt-4[^-\s]/.test(this.modelOptions.model)
- ? 'o200k_base'
- : '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);
- }
-
- /**
- * Calculate the token cost for an image based on its dimensions and detail level.
- *
- * @param {Object} image - The image object.
- * @param {number} image.width - The width of the image.
- * @param {number} image.height - The height of the image.
- * @param {'low'|'high'|string|undefined} [image.detail] - The detail level ('low', 'high', or other).
- * @returns {number} The calculated token cost.
- */
- calculateImageTokenCost({ width, height, detail }) {
- if (detail === 'low') {
- return ImageDetailCost.LOW;
- }
-
- // Calculate the number of 512px squares
- const numSquares = Math.ceil(width / 512) * Math.ceil(height / 512);
-
- // Default to high detail cost calculation
- return numSquares * ImageDetailCost.HIGH + ImageDetailCost.ADDITIONAL;
- }
-
- getSaveOptions() {
- return {
- artifacts: this.options.artifacts,
- maxContextTokens: this.options.maxContextTokens,
- chatGptLabel: this.options.chatGptLabel,
- promptPrefix: this.options.promptPrefix,
- resendFiles: this.options.resendFiles,
- imageDetail: this.options.imageDetail,
- modelLabel: this.options.modelLabel,
- iconURL: this.options.iconURL,
- greeting: this.options.greeting,
- spec: this.options.spec,
- ...this.modelOptions,
- };
- }
-
- getBuildMessagesOptions(opts) {
- return {
- isChatCompletion: this.isChatCompletion,
- promptPrefix: opts.promptPrefix,
- abortController: opts.abortController,
- };
- }
-
- /**
- *
- * Adds image URLs to the message object and returns the files
- *
- * @param {TMessage[]} messages
- * @param {MongoFile[]} files
- * @returns {Promise}
- */
- async addImageURLs(message, attachments) {
- const { files, image_urls } = await encodeAndFormat(this.options.req, attachments, {
- endpoint: this.options.endpoint,
- });
- message.image_urls = image_urls.length ? image_urls : undefined;
- return files;
- }
-
- async buildMessages(messages, parentMessageId, { promptPrefix = null }, opts) {
- let orderedMessages = this.constructor.getMessagesForConversation({
- messages,
- parentMessageId,
- summary: this.shouldSummarize,
- });
-
- let payload;
- let instructions;
- let tokenCountMap;
- let promptTokens;
-
- promptPrefix = (promptPrefix || this.options.promptPrefix || '').trim();
- if (typeof this.options.artifactsPrompt === 'string' && this.options.artifactsPrompt) {
- promptPrefix = `${promptPrefix ?? ''}\n${this.options.artifactsPrompt}`.trim();
- }
-
- if (this.options.attachments) {
- const attachments = await this.options.attachments;
-
- if (this.message_file_map) {
- this.message_file_map[orderedMessages[orderedMessages.length - 1].messageId] = attachments;
- } else {
- this.message_file_map = {
- [orderedMessages[orderedMessages.length - 1].messageId]: attachments,
- };
- }
-
- const files = await this.addImageURLs(
- orderedMessages[orderedMessages.length - 1],
- 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 = formatMessage({
- message,
- userName: this.options?.name,
- assistantName: this.options?.chatGptLabel,
- });
-
- 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,
- detail: this.options.imageDetail ?? ImageDetail.auto,
- });
- }
- }
-
- return formattedMessage;
- });
-
- if (this.contextHandlers) {
- this.augmentedPrompt = await this.contextHandlers.createContext();
- promptPrefix = this.augmentedPrompt + promptPrefix;
- }
-
- const noSystemModelRegex = /\b(o1-preview|o1-mini)\b/i.test(this.modelOptions.model);
-
- if (promptPrefix && !noSystemModelRegex) {
- promptPrefix = `Instructions:\n${promptPrefix.trim()}`;
- instructions = {
- role: 'system',
- content: promptPrefix,
- };
-
- if (this.contextStrategy) {
- instructions.tokenCount = this.getTokenCountForMessage(instructions);
- }
- }
-
- // TODO: need to handle interleaving instructions better
- if (this.contextStrategy) {
- ({ payload, tokenCountMap, promptTokens, messages } = await this.handleContextStrategy({
- instructions,
- orderedMessages,
- formattedMessages,
- }));
- }
-
- const result = {
- prompt: payload,
- promptTokens,
- messages,
- };
-
- /** EXPERIMENTAL */
- if (promptPrefix && noSystemModelRegex) {
- const lastUserMessageIndex = payload.findLastIndex((message) => message.role === 'user');
- if (lastUserMessageIndex !== -1) {
- if (Array.isArray(payload[lastUserMessageIndex].content)) {
- const firstTextPartIndex = payload[lastUserMessageIndex].content.findIndex(
- (part) => part.type === ContentTypes.TEXT,
- );
- if (firstTextPartIndex !== -1) {
- const firstTextPart = payload[lastUserMessageIndex].content[firstTextPartIndex];
- payload[lastUserMessageIndex].content[firstTextPartIndex].text =
- `${promptPrefix}\n${firstTextPart.text}`;
- } else {
- payload[lastUserMessageIndex].content.unshift({
- type: ContentTypes.TEXT,
- text: promptPrefix,
- });
- }
- } else {
- payload[lastUserMessageIndex].content =
- `${promptPrefix}\n${payload[lastUserMessageIndex].content}`;
- }
- }
- }
-
- if (tokenCountMap) {
- tokenCountMap.instructions = instructions?.tokenCount;
- result.tokenCountMap = tokenCountMap;
- }
-
- if (promptTokens >= 0 && typeof opts?.getReqData === 'function') {
- opts.getReqData({ promptTokens });
- }
-
- return result;
- }
-
- /** @type {sendCompletion} */
- async sendCompletion(payload, opts = {}) {
- let reply = '';
- let result = null;
- let streamResult = null;
- this.modelOptions.user = this.user;
- const invalidBaseUrl = this.completionsUrl && extractBaseURL(this.completionsUrl) === null;
- const useOldMethod = !!(invalidBaseUrl || !this.isChatCompletion);
- if (typeof opts.onProgress === 'function' && useOldMethod) {
- const completionResult = await this.getCompletion(
- payload,
- (progressMessage) => {
- if (progressMessage === '[DONE]') {
- return;
- }
-
- if (progressMessage.choices) {
- streamResult = progressMessage;
- }
-
- let token = null;
- if (this.isChatCompletion) {
- token =
- progressMessage.choices?.[0]?.delta?.content ?? progressMessage.choices?.[0]?.text;
- } else {
- token = progressMessage.choices?.[0]?.text;
- }
-
- if (!token && this.useOpenRouter) {
- token = progressMessage.choices?.[0]?.message?.content;
- }
- // first event's delta content is always undefined
- if (!token) {
- return;
- }
-
- if (token === this.endToken) {
- return;
- }
- opts.onProgress(token);
- reply += token;
- },
- opts.onProgress,
- opts.abortController || new AbortController(),
- );
-
- if (completionResult && typeof completionResult === 'string') {
- reply = completionResult;
- } else if (
- completionResult &&
- typeof completionResult === 'object' &&
- Array.isArray(completionResult.choices)
- ) {
- reply = completionResult.choices[0]?.text?.replace(this.endToken, '');
- }
- } else if (typeof opts.onProgress === 'function' || this.options.useChatCompletion) {
- reply = await this.chatCompletion({
- payload,
- onProgress: opts.onProgress,
- abortController: opts.abortController,
- });
- } else {
- result = await this.getCompletion(
- payload,
- null,
- opts.onProgress,
- opts.abortController || new AbortController(),
- );
-
- if (result && typeof result === 'string') {
- return result.trim();
- }
-
- logger.debug('[OpenAIClient] sendCompletion: result', { ...result });
-
- if (this.isChatCompletion) {
- reply = result.choices[0].message.content;
- } else {
- reply = result.choices[0].text.replace(this.endToken, '');
- }
- }
-
- if (streamResult) {
- const { finish_reason } = streamResult.choices[0];
- this.metadata = { finish_reason };
- }
- return (reply ?? '').trim();
- }
-
- initializeLLM() {
- throw new Error('Deprecated');
- }
-
- /**
- * Get stream usage as returned by this client's API response.
- * @returns {OpenAIUsageMetadata} The stream usage object.
- */
- getStreamUsage() {
- if (
- this.usage &&
- typeof this.usage === 'object' &&
- 'completion_tokens_details' in this.usage &&
- this.usage.completion_tokens_details &&
- typeof this.usage.completion_tokens_details === 'object' &&
- 'reasoning_tokens' in this.usage.completion_tokens_details
- ) {
- const outputTokens = Math.abs(
- this.usage.completion_tokens_details.reasoning_tokens - this.usage[this.outputTokensKey],
- );
- return {
- ...this.usage.completion_tokens_details,
- [this.inputTokensKey]: this.usage[this.inputTokensKey],
- [this.outputTokensKey]: outputTokens,
- };
- }
- return this.usage;
- }
-
- /**
- * Calculates the correct token count for the current user message based on the token count map and API usage.
- * Edge case: If the calculation results in a negative value, it returns the original estimate.
- * If revisiting a conversation with a chat history entirely composed of token estimates,
- * the cumulative token count going forward should become more accurate as the conversation progresses.
- * @param {Object} params - The parameters for the calculation.
- * @param {Record} params.tokenCountMap - A map of message IDs to their token counts.
- * @param {string} params.currentMessageId - The ID of the current message to calculate.
- * @param {OpenAIUsageMetadata} params.usage - The usage object returned by the API.
- * @returns {number} The correct token count for the current user message.
- */
- calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage }) {
- const originalEstimate = tokenCountMap[currentMessageId] || 0;
-
- if (!usage || typeof usage[this.inputTokensKey] !== 'number') {
- return originalEstimate;
- }
-
- tokenCountMap[currentMessageId] = 0;
- const totalTokensFromMap = Object.values(tokenCountMap).reduce((sum, count) => {
- const numCount = Number(count);
- return sum + (isNaN(numCount) ? 0 : numCount);
- }, 0);
- const totalInputTokens = usage[this.inputTokensKey] ?? 0;
-
- const currentMessageTokens = totalInputTokens - totalTokensFromMap;
- return currentMessageTokens > 0 ? currentMessageTokens : originalEstimate;
- }
-
- /**
- * @param {object} params
- * @param {number} params.promptTokens
- * @param {number} params.completionTokens
- * @param {OpenAIUsageMetadata} [params.usage]
- * @param {string} [params.model]
- * @param {string} [params.context='message']
- * @returns {Promise}
- */
- async recordTokenUsage({ promptTokens, completionTokens, usage, context = 'message' }) {
- await spendTokens(
- {
- context,
- model: this.modelOptions.model,
- conversationId: this.conversationId,
- user: this.user ?? this.options.req.user?.id,
- endpointTokenConfig: this.options.endpointTokenConfig,
- },
- { promptTokens, completionTokens },
- );
-
- if (
- usage &&
- typeof usage === 'object' &&
- 'reasoning_tokens' in usage &&
- typeof usage.reasoning_tokens === 'number'
- ) {
- await spendTokens(
- {
- context: 'reasoning',
- model: this.modelOptions.model,
- conversationId: this.conversationId,
- user: this.user ?? this.options.req.user?.id,
- endpointTokenConfig: this.options.endpointTokenConfig,
- },
- { completionTokens: usage.reasoning_tokens },
- );
- }
- }
-
- getTokenCountForResponse(response) {
- return this.getTokenCountForMessage({
- role: 'assistant',
- content: response.text,
- });
- }
-
- /**
- *
- * @param {string[]} [intermediateReply]
- * @returns {string}
- */
- getStreamText(intermediateReply) {
- if (!this.streamHandler) {
- return intermediateReply?.join('') ?? '';
- }
-
- let thinkMatch;
- let remainingText;
- let reasoningText = '';
-
- if (this.streamHandler.reasoningTokens.length > 0) {
- reasoningText = this.streamHandler.reasoningTokens.join('');
- thinkMatch = reasoningText.match(/([\s\S]*?)<\/think>/)?.[1]?.trim();
- if (thinkMatch != null && thinkMatch) {
- const reasoningTokens = `:::thinking\n${thinkMatch}\n:::\n`;
- remainingText = reasoningText.split(/<\/think>/)?.[1]?.trim() || '';
- return `${reasoningTokens}${remainingText}${this.streamHandler.tokens.join('')}`;
- } else if (thinkMatch === '') {
- remainingText = reasoningText.split(/<\/think>/)?.[1]?.trim() || '';
- return `${remainingText}${this.streamHandler.tokens.join('')}`;
- }
- }
-
- const reasoningTokens =
- reasoningText.length > 0
- ? `:::thinking\n${reasoningText.replace('', '').replace('', '').trim()}\n:::\n`
- : '';
-
- return `${reasoningTokens}${this.streamHandler.tokens.join('')}`;
- }
-
- 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;
- };
- }
-
- async chatCompletion({ payload, onProgress, abortController = null }) {
- const appConfig = this.options.req?.config;
- let error = null;
- let intermediateReply = [];
- const errorCallback = (err) => (error = err);
- try {
- if (!abortController) {
- abortController = new AbortController();
- }
-
- let modelOptions = { ...this.modelOptions };
-
- if (typeof onProgress === 'function') {
- modelOptions.stream = true;
- }
- if (this.isChatCompletion) {
- modelOptions.messages = payload;
- } else {
- modelOptions.prompt = payload;
- }
-
- const baseURL = extractBaseURL(this.completionsUrl);
- logger.debug('[OpenAIClient] chatCompletion', { baseURL, modelOptions });
- const opts = {
- baseURL,
- fetchOptions: {},
- };
-
- if (this.useOpenRouter) {
- opts.defaultHeaders = {
- 'HTTP-Referer': 'https://librechat.ai',
- 'X-Title': 'LibreChat',
- };
- }
-
- if (this.options.headers) {
- opts.defaultHeaders = { ...opts.defaultHeaders, ...this.options.headers };
- }
-
- if (this.options.defaultQuery) {
- opts.defaultQuery = this.options.defaultQuery;
- }
-
- if (this.options.proxy) {
- opts.fetchOptions.agent = new HttpsProxyAgent(this.options.proxy);
- }
-
- const azureConfig = appConfig?.endpoints?.[EModelEndpoint.azureOpenAI];
-
- if (
- (this.azure && this.isVisionModel && azureConfig) ||
- (azureConfig && this.isVisionModel && this.options.endpoint === EModelEndpoint.azureOpenAI)
- ) {
- const { modelGroupMap, groupMap } = azureConfig;
- const {
- azureOptions,
- baseURL,
- headers = {},
- serverless,
- } = mapModelToAzureConfig({
- modelName: modelOptions.model,
- modelGroupMap,
- groupMap,
- });
- opts.defaultHeaders = resolveHeaders({ headers });
- this.langchainProxy = extractBaseURL(baseURL);
- this.apiKey = azureOptions.azureOpenAIApiKey;
-
- const groupName = modelGroupMap[modelOptions.model].group;
- this.options.addParams = azureConfig.groupMap[groupName].addParams;
- this.options.dropParams = azureConfig.groupMap[groupName].dropParams;
- // Note: `forcePrompt` not re-assigned as only chat models are vision models
-
- this.azure = !serverless && azureOptions;
- this.azureEndpoint =
- !serverless && genAzureChatCompletion(this.azure, modelOptions.model, this);
- if (serverless === true) {
- this.options.defaultQuery = azureOptions.azureOpenAIApiVersion
- ? { 'api-version': azureOptions.azureOpenAIApiVersion }
- : undefined;
- this.options.headers['api-key'] = this.apiKey;
- }
- }
-
- if (this.azure || this.options.azure) {
- /* Azure Bug, extremely short default `max_tokens` response */
- if (!modelOptions.max_tokens && modelOptions.model === 'gpt-4-vision-preview') {
- modelOptions.max_tokens = 4000;
- }
-
- /* Azure does not accept `model` in the body, so we need to remove it. */
- delete modelOptions.model;
-
- opts.baseURL = this.langchainProxy
- ? constructAzureURL({
- baseURL: this.langchainProxy,
- azureOptions: this.azure,
- })
- : this.azureEndpoint.split(/(? msg.role === 'system');
-
- if (systemMessageIndex > 0) {
- const [systemMessage] = messages.splice(systemMessageIndex, 1);
- messages.unshift(systemMessage);
- }
-
- modelOptions.messages = messages;
- }
-
- /* If there is only one message and it's a system message, change the role to user */
- if (
- (opts.baseURL.includes('api.mistral.ai') || opts.baseURL.includes('api.perplexity.ai')) &&
- modelOptions.messages &&
- modelOptions.messages.length === 1 &&
- modelOptions.messages[0]?.role === 'system'
- ) {
- modelOptions.messages[0].role = 'user';
- }
-
- if (
- (this.options.endpoint === EModelEndpoint.openAI ||
- this.options.endpoint === EModelEndpoint.azureOpenAI) &&
- modelOptions.stream === true
- ) {
- modelOptions.stream_options = { include_usage: true };
- }
-
- if (this.options.addParams && typeof this.options.addParams === 'object') {
- const addParams = { ...this.options.addParams };
- modelOptions = {
- ...modelOptions,
- ...addParams,
- };
- logger.debug('[OpenAIClient] chatCompletion: added params', {
- addParams: addParams,
- modelOptions,
- });
- }
-
- /** Note: OpenAI Web Search models do not support any known parameters besdies `max_tokens` */
- if (modelOptions.model && /gpt-4o.*search/.test(modelOptions.model)) {
- const searchExcludeParams = [
- 'frequency_penalty',
- 'presence_penalty',
- 'temperature',
- 'top_p',
- 'top_k',
- 'stop',
- 'logit_bias',
- 'seed',
- 'response_format',
- 'n',
- 'logprobs',
- 'user',
- ];
-
- this.options.dropParams = this.options.dropParams || [];
- this.options.dropParams = [
- ...new Set([...this.options.dropParams, ...searchExcludeParams]),
- ];
- }
-
- if (this.options.dropParams && Array.isArray(this.options.dropParams)) {
- const dropParams = [...this.options.dropParams];
- dropParams.forEach((param) => {
- delete modelOptions[param];
- });
- logger.debug('[OpenAIClient] chatCompletion: dropped params', {
- dropParams: dropParams,
- modelOptions,
- });
- }
-
- const streamRate = this.options.streamRate ?? Constants.DEFAULT_STREAM_RATE;
-
- if (this.message_file_map && this.isOllama) {
- const ollamaClient = new OllamaClient({ baseURL, streamRate });
- return await ollamaClient.chatCompletion({
- payload: modelOptions,
- onProgress,
- abortController,
- });
- }
-
- let UnexpectedRoleError = false;
- /** @type {Promise} */
- let streamPromise;
- /** @type {(value: void | PromiseLike) => void} */
- let streamResolve;
-
- if (
- (!this.isOmni || /^o1-(mini|preview)/i.test(modelOptions.model)) &&
- modelOptions.reasoning_effort != null
- ) {
- delete modelOptions.reasoning_effort;
- delete modelOptions.temperature;
- }
-
- let reasoningKey = 'reasoning_content';
- if (this.useOpenRouter) {
- modelOptions.include_reasoning = true;
- reasoningKey = 'reasoning';
- }
- if (this.useOpenRouter && modelOptions.reasoning_effort != null) {
- modelOptions.reasoning = {
- effort: modelOptions.reasoning_effort,
- };
- delete modelOptions.reasoning_effort;
- }
-
- const handlers = createStreamEventHandlers(this.options.res);
- this.streamHandler = new SplitStreamHandler({
- reasoningKey,
- accumulate: true,
- runId: this.responseMessageId,
- handlers,
- });
-
- intermediateReply = this.streamHandler.tokens;
-
- if (modelOptions.stream) {
- streamPromise = new Promise((resolve) => {
- streamResolve = resolve;
- });
- /** @type {OpenAI.OpenAI.CompletionCreateParamsStreaming} */
- const params = {
- ...modelOptions,
- stream: true,
- };
- const stream = await openai.chat.completions
- .stream(params)
- .on('abort', () => {
- /* Do nothing here */
- })
- .on('error', (err) => {
- handleOpenAIErrors(err, errorCallback, 'stream');
- })
- .on('finalChatCompletion', async (finalChatCompletion) => {
- const finalMessage = finalChatCompletion?.choices?.[0]?.message;
- if (!finalMessage) {
- return;
- }
- await streamPromise;
- if (finalMessage?.role !== 'assistant') {
- finalChatCompletion.choices[0].message.role = 'assistant';
- }
-
- if (typeof finalMessage.content !== 'string' || finalMessage.content.trim() === '') {
- finalChatCompletion.choices[0].message.content = this.streamHandler.tokens.join('');
- }
- })
- .on('finalMessage', (message) => {
- if (message?.role !== 'assistant') {
- stream.messages.push({
- role: 'assistant',
- content: this.streamHandler.tokens.join(''),
- });
- UnexpectedRoleError = true;
- }
- });
-
- if (this.continued === true) {
- const latestText = addSpaceIfNeeded(
- this.currentMessages[this.currentMessages.length - 1]?.text ?? '',
- );
- this.streamHandler.handle({
- choices: [
- {
- delta: {
- content: latestText,
- },
- },
- ],
- });
- }
-
- for await (const chunk of stream) {
- // Add finish_reason: null if missing in any choice
- if (chunk.choices) {
- chunk.choices.forEach((choice) => {
- if (!('finish_reason' in choice)) {
- choice.finish_reason = null;
- }
- });
- }
- this.streamHandler.handle(chunk);
- if (abortController.signal.aborted) {
- stream.controller.abort();
- break;
- }
-
- await sleep(streamRate);
- }
-
- streamResolve();
-
- if (!UnexpectedRoleError) {
- chatCompletion = await stream.finalChatCompletion().catch((err) => {
- handleOpenAIErrors(err, errorCallback, 'finalChatCompletion');
- });
- }
- }
- // regular completion
- else {
- chatCompletion = await openai.chat.completions
- .create({
- ...modelOptions,
- })
- .catch((err) => {
- handleOpenAIErrors(err, errorCallback, 'create');
- });
- }
-
- if (openai.abortHandler && abortController.signal) {
- abortController.signal.removeEventListener('abort', openai.abortHandler);
- openai.abortHandler = undefined;
- }
-
- if (!chatCompletion && UnexpectedRoleError) {
- throw new Error(
- 'OpenAI error: Invalid final message: OpenAI expects final message to include role=assistant',
- );
- } else if (!chatCompletion && error) {
- throw new Error(error);
- } else if (!chatCompletion) {
- throw new Error('Chat completion failed');
- }
-
- const { choices } = chatCompletion;
- this.usage = chatCompletion.usage;
-
- if (!Array.isArray(choices) || choices.length === 0) {
- logger.warn('[OpenAIClient] Chat completion response has no choices');
- return this.streamHandler.tokens.join('');
- }
-
- const { message, finish_reason } = choices[0] ?? {};
- this.metadata = { finish_reason };
-
- logger.debug('[OpenAIClient] chatCompletion response', chatCompletion);
-
- if (!message) {
- logger.warn('[OpenAIClient] Message is undefined in chatCompletion response');
- return this.streamHandler.tokens.join('');
- }
-
- if (typeof message.content !== 'string' || message.content.trim() === '') {
- const reply = this.streamHandler.tokens.join('');
- logger.debug(
- '[OpenAIClient] chatCompletion: using intermediateReply due to empty message.content',
- { intermediateReply: reply },
- );
- return reply;
- }
-
- if (
- this.streamHandler.reasoningTokens.length > 0 &&
- this.options.context !== 'title' &&
- !message.content.startsWith('')
- ) {
- return this.getStreamText();
- } else if (
- this.streamHandler.reasoningTokens.length > 0 &&
- this.options.context !== 'title' &&
- message.content.startsWith('')
- ) {
- return this.getStreamText();
- }
-
- return message.content;
- } catch (err) {
- if (
- err?.message?.includes('abort') ||
- (err instanceof OpenAI.APIError && err?.message?.includes('abort'))
- ) {
- return this.getStreamText(intermediateReply);
- }
- if (
- err?.message?.includes(
- 'OpenAI error: Invalid final message: OpenAI expects final message to include role=assistant',
- ) ||
- err?.message?.includes(
- 'stream ended without producing a ChatCompletionMessage with role=assistant',
- ) ||
- err?.message?.includes('The server had an error processing your request') ||
- err?.message?.includes('missing finish_reason') ||
- err?.message?.includes('missing role') ||
- (err instanceof OpenAI.OpenAIError && err?.message?.includes('missing finish_reason'))
- ) {
- logger.error('[OpenAIClient] Known OpenAI error:', err);
- if (this.streamHandler && this.streamHandler.reasoningTokens.length) {
- return this.getStreamText();
- } else if (intermediateReply.length > 0) {
- return this.getStreamText(intermediateReply);
- } else {
- throw err;
- }
- } else if (err instanceof OpenAI.APIError) {
- if (this.streamHandler && this.streamHandler.reasoningTokens.length) {
- return this.getStreamText();
- } else if (intermediateReply.length > 0) {
- return this.getStreamText(intermediateReply);
- } else {
- throw err;
- }
- } else {
- logger.error('[OpenAIClient.chatCompletion] Unhandled error type', err);
- throw err;
- }
- }
- }
-}
-
-module.exports = OpenAIClient;
diff --git a/api/app/clients/document/index.js b/api/app/clients/document/index.js
deleted file mode 100644
index 9ff3da72f0..0000000000
--- a/api/app/clients/document/index.js
+++ /dev/null
@@ -1,5 +0,0 @@
-const tokenSplit = require('./tokenSplit');
-
-module.exports = {
- tokenSplit,
-};
diff --git a/api/app/clients/document/tokenSplit.js b/api/app/clients/document/tokenSplit.js
deleted file mode 100644
index 497249c519..0000000000
--- a/api/app/clients/document/tokenSplit.js
+++ /dev/null
@@ -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} 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;
diff --git a/api/app/clients/document/tokenSplit.spec.js b/api/app/clients/document/tokenSplit.spec.js
deleted file mode 100644
index d39c7d73cd..0000000000
--- a/api/app/clients/document/tokenSplit.spec.js
+++ /dev/null
@@ -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([]);
- });
-});
diff --git a/api/app/clients/index.js b/api/app/clients/index.js
index d8b2bae27b..3dbe397b31 100644
--- a/api/app/clients/index.js
+++ b/api/app/clients/index.js
@@ -1,13 +1,7 @@
-const OpenAIClient = require('./OpenAIClient');
-const GoogleClient = require('./GoogleClient');
const TextStream = require('./TextStream');
-const AnthropicClient = require('./AnthropicClient');
const toolUtils = require('./tools/util');
module.exports = {
- OpenAIClient,
- GoogleClient,
TextStream,
- AnthropicClient,
...toolUtils,
};
diff --git a/api/app/clients/llm/createCoherePayload.js b/api/app/clients/llm/createCoherePayload.js
deleted file mode 100644
index 58803d76f3..0000000000
--- a/api/app/clients/llm/createCoherePayload.js
+++ /dev/null
@@ -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;
diff --git a/api/app/clients/llm/index.js b/api/app/clients/llm/index.js
deleted file mode 100644
index c7770ce103..0000000000
--- a/api/app/clients/llm/index.js
+++ /dev/null
@@ -1,5 +0,0 @@
-const createCoherePayload = require('./createCoherePayload');
-
-module.exports = {
- createCoherePayload,
-};
diff --git a/api/app/clients/output_parsers/addImages.js b/api/app/clients/output_parsers/addImages.js
deleted file mode 100644
index f0860ef8bd..0000000000
--- a/api/app/clients/output_parsers/addImages.js
+++ /dev/null
@@ -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.
+ );
+}`;
+
+ const artifactText = `${ARTIFACT_START}{identifier="react-app" type="application/vnd.react" title="React App"}
+\`\`\`jsx
+${jsxContent}
+\`\`\`
+${ARTIFACT_END}`;
+
+ const message = { text: artifactText };
+ const artifacts = findAllArtifacts(message);
+
+ const updatedJsx = jsxContent.replace('Increment', 'Click me');
+ const result = replaceArtifactContent(artifactText, artifacts[0], jsxContent, updatedJsx);
+
+ expect(result).not.toBeNull();
+ expect(result).toContain('Click me');
+ expect(result).not.toContain('Increment');
+ expect(result).toMatch(/```jsx\n/);
+ });
+
+ test('should handle mermaid diagram content', () => {
+ const mermaidContent = `graph TD
+ A[Start] --> B{Is it?}
+ B -->|Yes| C[OK]
+ B -->|No| D[End]`;
+
+ const artifactText = `${ARTIFACT_START}{identifier="diagram" type="application/vnd.mermaid" title="Flow"}
+\`\`\`mermaid
+${mermaidContent}
+\`\`\`
+${ARTIFACT_END}`;
+
+ const message = { text: artifactText };
+ const artifacts = findAllArtifacts(message);
+
+ const updatedMermaid = mermaidContent.replace('Start', 'Begin');
+ const result = replaceArtifactContent(
+ artifactText,
+ artifacts[0],
+ mermaidContent,
+ updatedMermaid,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result).toContain('Begin');
+ expect(result).toMatch(/```mermaid\n/);
+ });
+
+ test('should handle artifact without code block (plain text)', () => {
+ const content = 'Just plain text without code fences';
+ const artifactText = `${ARTIFACT_START}{identifier="plain" type="text/plain" title="Plain"}
+${content}
+${ARTIFACT_END}`;
+
+ const message = { text: artifactText };
+ const artifacts = findAllArtifacts(message);
+
+ const result = replaceArtifactContent(
+ artifactText,
+ artifacts[0],
+ content,
+ 'updated plain text',
+ );
+
+ expect(result).not.toBeNull();
+ expect(result).toContain('updated plain text');
+ expect(result).not.toContain('```');
+ });
+
+ test('should handle multiline content with various newline patterns', () => {
+ const content = `Line 1
+Line 2
+
+Line 4 after empty line
+ Indented line
+ Double indented`;
+
+ const artifactText = `${ARTIFACT_START}{identifier="test" type="text/plain" title="Test"}
+\`\`\`
+${content}
+\`\`\`
+${ARTIFACT_END}`;
+
+ const message = { text: artifactText };
+ const artifacts = findAllArtifacts(message);
+
+ const updated = content.replace('Line 1', 'First Line');
+ const result = replaceArtifactContent(artifactText, artifacts[0], content, updated);
+
+ expect(result).not.toBeNull();
+ expect(result).toContain('First Line');
+ expect(result).toContain(' Indented line');
+ expect(result).toContain(' Double indented');
+ });
});
});
diff --git a/api/server/services/AssistantService.js b/api/server/services/AssistantService.js
index 892afb7002..a7018f715b 100644
--- a/api/server/services/AssistantService.js
+++ b/api/server/services/AssistantService.js
@@ -23,7 +23,7 @@ const { TextStream } = require('~/app/clients');
* Sorts, processes, and flattens messages to a single string.
*
* @param {Object} params - Params for creating the onTextProgress function.
- * @param {OpenAIClient} params.openai - The OpenAI client instance.
+ * @param {OpenAI} params.openai - The OpenAI SDK client instance.
* @param {string} params.conversationId - The current conversation ID.
* @param {string} params.userMessageId - The user message ID; response's `parentMessageId`.
* @param {string} params.messageId - The response message ID.
@@ -74,7 +74,7 @@ async function createOnTextProgress({
* Retrieves the response from an OpenAI run.
*
* @param {Object} params - The parameters for getting the response.
- * @param {OpenAIClient} params.openai - The OpenAI client instance.
+ * @param {OpenAI} params.openai - The OpenAI SDK client instance.
* @param {string} params.run_id - The ID of the run to get the response for.
* @param {string} params.thread_id - The ID of the thread associated with the run.
* @return {Promise}
@@ -162,7 +162,7 @@ function hasToolCallChanged(previousCall, currentCall) {
* Creates a handler function for steps in progress, specifically for
* processing messages and managing seen completed messages.
*
- * @param {OpenAIClient} openai - The OpenAI client instance.
+ * @param {OpenAI} openai - The OpenAI SDK client instance.
* @param {string} thread_id - The ID of the thread the run is in.
* @param {ThreadMessage[]} messages - The accumulated messages for the run.
* @return {InProgressFunction} a function to handle steps in progress.
@@ -334,7 +334,7 @@ function createInProgressHandler(openai, thread_id, messages) {
* Initializes a RunManager with handlers, then invokes waitForRun to monitor and manage an OpenAI run.
*
* @param {Object} params - The parameters for managing and monitoring the run.
- * @param {OpenAIClient} params.openai - The OpenAI client instance.
+ * @param {OpenAI} params.openai - The OpenAI SDK client instance.
* @param {string} params.run_id - The ID of the run to manage and monitor.
* @param {string} params.thread_id - The ID of the thread associated with the run.
* @param {RunStep[]} params.accumulatedSteps - The accumulated steps for the run.
diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js
index 72bda67322..ef50a365b9 100644
--- a/api/server/services/AuthService.js
+++ b/api/server/services/AuthService.js
@@ -1,9 +1,19 @@
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { webcrypto } = require('node:crypto');
-const { logger } = require('@librechat/data-schemas');
-const { isEnabled, checkEmailConfig, isEmailDomainAllowed } = require('@librechat/api');
+const {
+ logger,
+ DEFAULT_SESSION_EXPIRY,
+ DEFAULT_REFRESH_TOKEN_EXPIRY,
+} = require('@librechat/data-schemas');
const { ErrorTypes, SystemRoles, errorsToString } = require('librechat-data-provider');
+const {
+ math,
+ isEnabled,
+ checkEmailConfig,
+ isEmailDomainAllowed,
+ shouldUseSecureCookie,
+} = require('@librechat/api');
const {
findUser,
findToken,
@@ -29,7 +39,6 @@ const domains = {
server: process.env.DOMAIN_SERVER,
};
-const isProduction = process.env.NODE_ENV === 'production';
const genericVerificationMessage = 'Please check your email to verify your email address.';
/**
@@ -369,30 +378,32 @@ const setAuthTokens = async (userId, res, _session = null) => {
let session = _session;
let refreshToken;
let refreshTokenExpires;
+ const expiresIn = math(process.env.REFRESH_TOKEN_EXPIRY, DEFAULT_REFRESH_TOKEN_EXPIRY);
if (session && session._id && session.expiration != null) {
refreshTokenExpires = session.expiration.getTime();
refreshToken = await generateRefreshToken(session);
} else {
- const result = await createSession(userId);
+ const result = await createSession(userId, { expiresIn });
session = result.session;
refreshToken = result.refreshToken;
refreshTokenExpires = session.expiration.getTime();
}
const user = await getUserById(userId);
- const token = await generateToken(user);
+ const sessionExpiry = math(process.env.SESSION_EXPIRY, DEFAULT_SESSION_EXPIRY);
+ const token = await generateToken(user, sessionExpiry);
res.cookie('refreshToken', refreshToken, {
expires: new Date(refreshTokenExpires),
httpOnly: true,
- secure: isProduction,
+ secure: shouldUseSecureCookie(),
sameSite: 'strict',
});
res.cookie('token_provider', 'librechat', {
expires: new Date(refreshTokenExpires),
httpOnly: true,
- secure: isProduction,
+ secure: shouldUseSecureCookie(),
sameSite: 'strict',
});
return token;
@@ -405,23 +416,26 @@ const setAuthTokens = async (userId, res, _session = null) => {
/**
* @function setOpenIDAuthTokens
* Set OpenID Authentication Tokens
- * //type tokenset from openid-client
+ * Stores tokens server-side in express-session to avoid large cookie sizes
+ * that can exceed HTTP/2 header limits (especially for users with many group memberships).
+ *
* @param {import('openid-client').TokenEndpointResponse & import('openid-client').TokenEndpointResponseHelpers} tokenset
* - The tokenset object containing access and refresh tokens
+ * @param {Object} req - request object (for session access)
* @param {Object} res - response object
* @param {string} [userId] - Optional MongoDB user ID for image path validation
- * @returns {String} - access token
+ * @returns {String} - id_token (preferred) or access_token as the app auth token
*/
-const setOpenIDAuthTokens = (tokenset, res, userId, existingRefreshToken) => {
+const setOpenIDAuthTokens = (tokenset, req, res, userId, existingRefreshToken) => {
try {
if (!tokenset) {
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
return;
}
- const { REFRESH_TOKEN_EXPIRY } = process.env ?? {};
- const expiryInMilliseconds = REFRESH_TOKEN_EXPIRY
- ? eval(REFRESH_TOKEN_EXPIRY)
- : 1000 * 60 * 60 * 24 * 7; // 7 days default
+ const expiryInMilliseconds = math(
+ process.env.REFRESH_TOKEN_EXPIRY,
+ DEFAULT_REFRESH_TOKEN_EXPIRY,
+ );
const expirationDate = new Date(Date.now() + expiryInMilliseconds);
if (tokenset == null) {
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
@@ -439,22 +453,62 @@ const setOpenIDAuthTokens = (tokenset, res, userId, existingRefreshToken) => {
return;
}
+ /**
+ * Use id_token as the app authentication token (Bearer token for JWKS validation).
+ * The id_token is always a standard JWT signed by the IdP's JWKS keys with the app's
+ * client_id as audience. The access_token may be opaque or intended for a different
+ * audience (e.g., Microsoft Graph API), which fails JWKS validation.
+ * Falls back to access_token for providers where id_token is not available.
+ */
+ const appAuthToken = tokenset.id_token || tokenset.access_token;
+
+ /**
+ * Always set refresh token cookie so it survives express session expiry.
+ * The session cookie maxAge (SESSION_EXPIRY, default 15 min) is typically shorter
+ * than the OIDC token lifetime (~1 hour). Without this cookie fallback, the refresh
+ * token stored only in the session is lost when the session expires, causing the user
+ * to be signed out on the next token refresh attempt.
+ * The refresh token is small (opaque string) so it doesn't hit the HTTP/2 header
+ * size limits that motivated session storage for the larger access_token/id_token.
+ */
res.cookie('refreshToken', refreshToken, {
expires: expirationDate,
httpOnly: true,
- secure: isProduction,
- sameSite: 'strict',
- });
- res.cookie('openid_access_token', tokenset.access_token, {
- expires: expirationDate,
- httpOnly: true,
- secure: isProduction,
+ secure: shouldUseSecureCookie(),
sameSite: 'strict',
});
+
+ /** Store tokens server-side in session to avoid large cookies */
+ if (req.session) {
+ req.session.openidTokens = {
+ accessToken: tokenset.access_token,
+ idToken: tokenset.id_token,
+ refreshToken: refreshToken,
+ expiresAt: expirationDate.getTime(),
+ };
+ } else {
+ logger.warn('[setOpenIDAuthTokens] No session available, falling back to cookies');
+ res.cookie('openid_access_token', tokenset.access_token, {
+ expires: expirationDate,
+ httpOnly: true,
+ secure: shouldUseSecureCookie(),
+ sameSite: 'strict',
+ });
+ if (tokenset.id_token) {
+ res.cookie('openid_id_token', tokenset.id_token, {
+ expires: expirationDate,
+ httpOnly: true,
+ secure: shouldUseSecureCookie(),
+ sameSite: 'strict',
+ });
+ }
+ }
+
+ /** Small cookie to indicate token provider (required for auth middleware) */
res.cookie('token_provider', 'openid', {
expires: expirationDate,
httpOnly: true,
- secure: isProduction,
+ secure: shouldUseSecureCookie(),
sameSite: 'strict',
});
if (userId && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
@@ -465,11 +519,11 @@ const setOpenIDAuthTokens = (tokenset, res, userId, existingRefreshToken) => {
res.cookie('openid_user_id', signedUserId, {
expires: expirationDate,
httpOnly: true,
- secure: isProduction,
+ secure: shouldUseSecureCookie(),
sameSite: 'strict',
});
}
- return tokenset.access_token;
+ return appAuthToken;
} catch (error) {
logger.error('[setOpenIDAuthTokens] Error in setting authentication tokens:', error);
throw error;
diff --git a/api/server/services/AuthService.spec.js b/api/server/services/AuthService.spec.js
new file mode 100644
index 0000000000..da78f8d775
--- /dev/null
+++ b/api/server/services/AuthService.spec.js
@@ -0,0 +1,269 @@
+jest.mock('@librechat/data-schemas', () => ({
+ logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
+ DEFAULT_SESSION_EXPIRY: 900000,
+ DEFAULT_REFRESH_TOKEN_EXPIRY: 604800000,
+}));
+jest.mock('librechat-data-provider', () => ({
+ ErrorTypes: {},
+ SystemRoles: { USER: 'USER', ADMIN: 'ADMIN' },
+ errorsToString: jest.fn(),
+}));
+jest.mock('@librechat/api', () => ({
+ isEnabled: jest.fn((val) => val === 'true' || val === true),
+ checkEmailConfig: jest.fn(),
+ isEmailDomainAllowed: jest.fn(),
+ math: jest.fn((val, fallback) => (val ? Number(val) : fallback)),
+ shouldUseSecureCookie: jest.fn(() => false),
+}));
+jest.mock('~/models', () => ({
+ findUser: jest.fn(),
+ findToken: jest.fn(),
+ createUser: jest.fn(),
+ updateUser: jest.fn(),
+ countUsers: jest.fn(),
+ getUserById: jest.fn(),
+ findSession: jest.fn(),
+ createToken: jest.fn(),
+ deleteTokens: jest.fn(),
+ deleteSession: jest.fn(),
+ createSession: jest.fn(),
+ generateToken: jest.fn(),
+ deleteUserById: jest.fn(),
+ generateRefreshToken: jest.fn(),
+}));
+jest.mock('~/strategies/validators', () => ({ registerSchema: { parse: jest.fn() } }));
+jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() }));
+jest.mock('~/server/utils', () => ({ sendEmail: jest.fn() }));
+
+const { shouldUseSecureCookie } = require('@librechat/api');
+const { setOpenIDAuthTokens } = require('./AuthService');
+
+/** Helper to build a mock Express response */
+function mockResponse() {
+ const cookies = {};
+ const res = {
+ cookie: jest.fn((name, value, options) => {
+ cookies[name] = { value, options };
+ }),
+ _cookies: cookies,
+ };
+ return res;
+}
+
+/** Helper to build a mock Express request with session */
+function mockRequest(sessionData = {}) {
+ return {
+ session: { openidTokens: null, ...sessionData },
+ };
+}
+
+describe('setOpenIDAuthTokens', () => {
+ const env = process.env;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ process.env = {
+ ...env,
+ JWT_REFRESH_SECRET: 'test-refresh-secret',
+ OPENID_REUSE_TOKENS: 'true',
+ };
+ });
+
+ afterAll(() => {
+ process.env = env;
+ });
+
+ describe('token selection (id_token vs access_token)', () => {
+ it('should return id_token when both id_token and access_token are present', () => {
+ const tokenset = {
+ id_token: 'the-id-token',
+ access_token: 'the-access-token',
+ refresh_token: 'the-refresh-token',
+ };
+ const req = mockRequest();
+ const res = mockResponse();
+
+ const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123');
+ expect(result).toBe('the-id-token');
+ });
+
+ it('should return access_token when id_token is not available', () => {
+ const tokenset = {
+ access_token: 'the-access-token',
+ refresh_token: 'the-refresh-token',
+ };
+ const req = mockRequest();
+ const res = mockResponse();
+
+ const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123');
+ expect(result).toBe('the-access-token');
+ });
+
+ it('should return access_token when id_token is undefined', () => {
+ const tokenset = {
+ id_token: undefined,
+ access_token: 'the-access-token',
+ refresh_token: 'the-refresh-token',
+ };
+ const req = mockRequest();
+ const res = mockResponse();
+
+ const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123');
+ expect(result).toBe('the-access-token');
+ });
+
+ it('should return access_token when id_token is null', () => {
+ const tokenset = {
+ id_token: null,
+ access_token: 'the-access-token',
+ refresh_token: 'the-refresh-token',
+ };
+ const req = mockRequest();
+ const res = mockResponse();
+
+ const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123');
+ expect(result).toBe('the-access-token');
+ });
+
+ it('should return id_token even when id_token and access_token differ', () => {
+ const tokenset = {
+ id_token: 'id-token-jwt-signed-by-idp',
+ access_token: 'opaque-graph-api-token',
+ refresh_token: 'refresh-token',
+ };
+ const req = mockRequest();
+ const res = mockResponse();
+
+ const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123');
+ expect(result).toBe('id-token-jwt-signed-by-idp');
+ expect(result).not.toBe('opaque-graph-api-token');
+ });
+ });
+
+ describe('session token storage', () => {
+ it('should store the original access_token in session (not id_token)', () => {
+ const tokenset = {
+ id_token: 'the-id-token',
+ access_token: 'the-access-token',
+ refresh_token: 'the-refresh-token',
+ };
+ const req = mockRequest();
+ const res = mockResponse();
+
+ setOpenIDAuthTokens(tokenset, req, res, 'user-123');
+
+ expect(req.session.openidTokens.accessToken).toBe('the-access-token');
+ expect(req.session.openidTokens.refreshToken).toBe('the-refresh-token');
+ });
+ });
+
+ describe('cookie secure flag', () => {
+ it('should call shouldUseSecureCookie for every cookie set', () => {
+ const tokenset = {
+ id_token: 'the-id-token',
+ access_token: 'the-access-token',
+ refresh_token: 'the-refresh-token',
+ };
+ const req = mockRequest();
+ const res = mockResponse();
+
+ setOpenIDAuthTokens(tokenset, req, res, 'user-123');
+
+ // token_provider + openid_user_id (session path, so no refreshToken/openid_access_token cookies)
+ const secureCalls = shouldUseSecureCookie.mock.calls.length;
+ expect(secureCalls).toBeGreaterThanOrEqual(2);
+
+ // Verify all cookies use the result of shouldUseSecureCookie
+ for (const [, cookie] of Object.entries(res._cookies)) {
+ expect(cookie.options.secure).toBe(false);
+ }
+ });
+
+ it('should set secure: true when shouldUseSecureCookie returns true', () => {
+ shouldUseSecureCookie.mockReturnValue(true);
+
+ const tokenset = {
+ id_token: 'the-id-token',
+ access_token: 'the-access-token',
+ refresh_token: 'the-refresh-token',
+ };
+ const req = mockRequest();
+ const res = mockResponse();
+
+ setOpenIDAuthTokens(tokenset, req, res, 'user-123');
+
+ for (const [, cookie] of Object.entries(res._cookies)) {
+ expect(cookie.options.secure).toBe(true);
+ }
+ });
+
+ it('should use shouldUseSecureCookie for cookie fallback path (no session)', () => {
+ shouldUseSecureCookie.mockReturnValue(false);
+
+ const tokenset = {
+ id_token: 'the-id-token',
+ access_token: 'the-access-token',
+ refresh_token: 'the-refresh-token',
+ };
+ const req = { session: null };
+ const res = mockResponse();
+
+ setOpenIDAuthTokens(tokenset, req, res, 'user-123');
+
+ // In the cookie fallback path, we get: refreshToken, openid_access_token, token_provider, openid_user_id
+ expect(res.cookie).toHaveBeenCalledWith(
+ 'refreshToken',
+ expect.any(String),
+ expect.objectContaining({ secure: false }),
+ );
+ expect(res.cookie).toHaveBeenCalledWith(
+ 'openid_access_token',
+ expect.any(String),
+ expect.objectContaining({ secure: false }),
+ );
+ expect(res.cookie).toHaveBeenCalledWith(
+ 'token_provider',
+ 'openid',
+ expect.objectContaining({ secure: false }),
+ );
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should return undefined when tokenset is null', () => {
+ const req = mockRequest();
+ const res = mockResponse();
+ const result = setOpenIDAuthTokens(null, req, res, 'user-123');
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined when access_token is missing', () => {
+ const tokenset = { refresh_token: 'refresh' };
+ const req = mockRequest();
+ const res = mockResponse();
+ const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123');
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined when no refresh token is available', () => {
+ const tokenset = { access_token: 'access', id_token: 'id' };
+ const req = mockRequest();
+ const res = mockResponse();
+ const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123');
+ expect(result).toBeUndefined();
+ });
+
+ it('should use existingRefreshToken when tokenset has no refresh_token', () => {
+ const tokenset = {
+ id_token: 'the-id-token',
+ access_token: 'the-access-token',
+ };
+ const req = mockRequest();
+ const res = mockResponse();
+
+ const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123', 'existing-refresh');
+ expect(result).toBe('the-id-token');
+ expect(req.session.openidTokens.refreshToken).toBe('existing-refresh');
+ });
+ });
+});
diff --git a/api/server/services/Config/EndpointService.js b/api/server/services/Config/EndpointService.js
index d8277dd67f..b10c0d2890 100644
--- a/api/server/services/Config/EndpointService.js
+++ b/api/server/services/Config/EndpointService.js
@@ -1,4 +1,4 @@
-const { isUserProvided } = require('@librechat/api');
+const { isUserProvided, isEnabled } = require('@librechat/api');
const { EModelEndpoint } = require('librechat-data-provider');
const { generateConfig } = require('~/server/utils/handleText');
@@ -8,8 +8,6 @@ const {
ASSISTANTS_API_KEY: assistantsApiKey,
AZURE_API_KEY: azureOpenAIApiKey,
ANTHROPIC_API_KEY: anthropicApiKey,
- CHATGPT_TOKEN: chatGPTToken,
- PLUGINS_USE_AZURE,
GOOGLE_KEY: googleKey,
OPENAI_REVERSE_PROXY,
AZURE_OPENAI_BASEURL,
@@ -17,21 +15,17 @@ const {
AZURE_ASSISTANTS_BASE_URL,
} = process.env ?? {};
-const useAzurePlugins = !!PLUGINS_USE_AZURE;
-
-const userProvidedOpenAI = useAzurePlugins
- ? isUserProvided(azureOpenAIApiKey)
- : isUserProvided(openAIApiKey);
+const userProvidedOpenAI = isUserProvided(openAIApiKey);
module.exports = {
config: {
+ googleKey,
openAIApiKey,
azureOpenAIApiKey,
- useAzurePlugins,
userProvidedOpenAI,
- googleKey,
- [EModelEndpoint.anthropic]: generateConfig(anthropicApiKey),
- [EModelEndpoint.chatGPTBrowser]: generateConfig(chatGPTToken),
+ [EModelEndpoint.anthropic]: generateConfig(
+ anthropicApiKey || isEnabled(process.env.ANTHROPIC_USE_VERTEX),
+ ),
[EModelEndpoint.openAI]: generateConfig(openAIApiKey, OPENAI_REVERSE_PROXY),
[EModelEndpoint.azureOpenAI]: generateConfig(azureOpenAIApiKey, AZURE_OPENAI_BASEURL),
[EModelEndpoint.assistants]: generateConfig(
diff --git a/api/server/services/Config/__tests__/getCachedTools.spec.js b/api/server/services/Config/__tests__/getCachedTools.spec.js
index 48ab6e0737..38d488ed38 100644
--- a/api/server/services/Config/__tests__/getCachedTools.spec.js
+++ b/api/server/services/Config/__tests__/getCachedTools.spec.js
@@ -1,10 +1,92 @@
-const { ToolCacheKeys } = require('../getCachedTools');
+const { CacheKeys } = require('librechat-data-provider');
+
+jest.mock('~/cache/getLogStores');
+const getLogStores = require('~/cache/getLogStores');
+
+const mockCache = { get: jest.fn(), set: jest.fn(), delete: jest.fn() };
+getLogStores.mockReturnValue(mockCache);
+
+const {
+ ToolCacheKeys,
+ getCachedTools,
+ setCachedTools,
+ getMCPServerTools,
+ invalidateCachedTools,
+} = require('../getCachedTools');
+
+describe('getCachedTools', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ getLogStores.mockReturnValue(mockCache);
+ });
-describe('getCachedTools - Cache Isolation Security', () => {
describe('ToolCacheKeys.MCP_SERVER', () => {
it('should generate cache keys that include userId', () => {
const key = ToolCacheKeys.MCP_SERVER('user123', 'github');
expect(key).toBe('tools:mcp:user123:github');
});
});
+
+ describe('TOOL_CACHE namespace usage', () => {
+ it('getCachedTools should use TOOL_CACHE namespace', async () => {
+ mockCache.get.mockResolvedValue(null);
+ await getCachedTools();
+ expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
+ });
+
+ it('getCachedTools with MCP server options should use TOOL_CACHE namespace', async () => {
+ mockCache.get.mockResolvedValue({ tool1: {} });
+ await getCachedTools({ userId: 'user1', serverName: 'github' });
+ expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
+ expect(mockCache.get).toHaveBeenCalledWith(ToolCacheKeys.MCP_SERVER('user1', 'github'));
+ });
+
+ it('setCachedTools should use TOOL_CACHE namespace', async () => {
+ mockCache.set.mockResolvedValue(true);
+ const tools = { tool1: { type: 'function' } };
+ await setCachedTools(tools);
+ expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
+ expect(mockCache.set).toHaveBeenCalledWith(ToolCacheKeys.GLOBAL, tools, expect.any(Number));
+ });
+
+ it('setCachedTools with MCP server options should use TOOL_CACHE namespace', async () => {
+ mockCache.set.mockResolvedValue(true);
+ const tools = { tool1: { type: 'function' } };
+ await setCachedTools(tools, { userId: 'user1', serverName: 'github' });
+ expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
+ expect(mockCache.set).toHaveBeenCalledWith(
+ ToolCacheKeys.MCP_SERVER('user1', 'github'),
+ tools,
+ expect.any(Number),
+ );
+ });
+
+ it('invalidateCachedTools should use TOOL_CACHE namespace', async () => {
+ mockCache.delete.mockResolvedValue(true);
+ await invalidateCachedTools({ invalidateGlobal: true });
+ expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
+ expect(mockCache.delete).toHaveBeenCalledWith(ToolCacheKeys.GLOBAL);
+ });
+
+ it('getMCPServerTools should use TOOL_CACHE namespace', async () => {
+ mockCache.get.mockResolvedValue(null);
+ await getMCPServerTools('user1', 'github');
+ expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
+ expect(mockCache.get).toHaveBeenCalledWith(ToolCacheKeys.MCP_SERVER('user1', 'github'));
+ });
+
+ it('should NOT use CONFIG_STORE namespace', async () => {
+ mockCache.get.mockResolvedValue(null);
+ await getCachedTools();
+ await getMCPServerTools('user1', 'github');
+ mockCache.set.mockResolvedValue(true);
+ await setCachedTools({ tool1: {} });
+ mockCache.delete.mockResolvedValue(true);
+ await invalidateCachedTools({ invalidateGlobal: true });
+
+ const allCalls = getLogStores.mock.calls.flat();
+ expect(allCalls).not.toContain(CacheKeys.CONFIG_STORE);
+ expect(allCalls.every((key) => key === CacheKeys.TOOL_CACHE)).toBe(true);
+ });
+ });
});
diff --git a/api/server/services/Config/getCachedTools.js b/api/server/services/Config/getCachedTools.js
index 841ca04c94..eb7a08305a 100644
--- a/api/server/services/Config/getCachedTools.js
+++ b/api/server/services/Config/getCachedTools.js
@@ -1,4 +1,4 @@
-const { CacheKeys } = require('librechat-data-provider');
+const { CacheKeys, Time } = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
/**
@@ -20,7 +20,7 @@ const ToolCacheKeys = {
* @returns {Promise} The available tools object or null if not cached
*/
async function getCachedTools(options = {}) {
- const cache = getLogStores(CacheKeys.CONFIG_STORE);
+ const cache = getLogStores(CacheKeys.TOOL_CACHE);
const { userId, serverName } = options;
// Return MCP server-specific tools if requested
@@ -39,12 +39,12 @@ async function getCachedTools(options = {}) {
* @param {Object} options - Options for caching tools
* @param {string} [options.userId] - User ID for user-specific MCP tools
* @param {string} [options.serverName] - MCP server name for server-specific tools
- * @param {number} [options.ttl] - Time to live in milliseconds
+ * @param {number} [options.ttl] - Time to live in milliseconds (default: 12 hours)
* @returns {Promise} Whether the operation was successful
*/
async function setCachedTools(tools, options = {}) {
- const cache = getLogStores(CacheKeys.CONFIG_STORE);
- const { userId, serverName, ttl } = options;
+ const cache = getLogStores(CacheKeys.TOOL_CACHE);
+ const { userId, serverName, ttl = Time.TWELVE_HOURS } = options;
// Cache by MCP server if specified (requires userId)
if (serverName && userId) {
@@ -65,7 +65,7 @@ async function setCachedTools(tools, options = {}) {
* @returns {Promise}
*/
async function invalidateCachedTools(options = {}) {
- const cache = getLogStores(CacheKeys.CONFIG_STORE);
+ const cache = getLogStores(CacheKeys.TOOL_CACHE);
const { userId, serverName, invalidateGlobal = false } = options;
const keysToDelete = [];
@@ -89,7 +89,7 @@ async function invalidateCachedTools(options = {}) {
* @returns {Promise} The available tools for the server
*/
async function getMCPServerTools(userId, serverName) {
- const cache = getLogStores(CacheKeys.CONFIG_STORE);
+ const cache = getLogStores(CacheKeys.TOOL_CACHE);
const serverTools = await cache.get(ToolCacheKeys.MCP_SERVER(userId, serverName));
if (serverTools) {
diff --git a/api/server/services/Config/getEndpointsConfig.js b/api/server/services/Config/getEndpointsConfig.js
index 82c8f01abe..bb22584851 100644
--- a/api/server/services/Config/getEndpointsConfig.js
+++ b/api/server/services/Config/getEndpointsConfig.js
@@ -19,7 +19,11 @@ async function getEndpointsConfig(req) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cachedEndpointsConfig = await cache.get(CacheKeys.ENDPOINT_CONFIG);
if (cachedEndpointsConfig) {
- return cachedEndpointsConfig;
+ if (cachedEndpointsConfig.gptPlugins) {
+ await cache.delete(CacheKeys.ENDPOINT_CONFIG);
+ } else {
+ return cachedEndpointsConfig;
+ }
}
const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role }));
@@ -39,6 +43,14 @@ async function getEndpointsConfig(req) {
};
}
+ // Enable Anthropic endpoint when Vertex AI is configured in YAML
+ if (appConfig.endpoints?.[EModelEndpoint.anthropic]?.vertexConfig?.enabled) {
+ /** @type {Omit} */
+ mergedConfig[EModelEndpoint.anthropic] = {
+ userProvide: false,
+ };
+ }
+
if (appConfig.endpoints?.[EModelEndpoint.azureOpenAI]?.assistants) {
/** @type {Omit} */
mergedConfig[EModelEndpoint.azureAssistants] = {
diff --git a/api/server/services/Config/loadAsyncEndpoints.js b/api/server/services/Config/loadAsyncEndpoints.js
index 48b42131e0..0d6a05aff7 100644
--- a/api/server/services/Config/loadAsyncEndpoints.js
+++ b/api/server/services/Config/loadAsyncEndpoints.js
@@ -1,17 +1,11 @@
const path = require('path');
const { logger } = require('@librechat/data-schemas');
-const { EModelEndpoint } = require('librechat-data-provider');
const { loadServiceKey, isUserProvided } = require('@librechat/api');
const { config } = require('./EndpointService');
-const { openAIApiKey, azureOpenAIApiKey, useAzurePlugins, userProvidedOpenAI, googleKey } = config;
-
-/**
- * Load async endpoints and return a configuration object
- * @param {AppConfig} [appConfig] - The app configuration object
- */
-async function loadAsyncEndpoints(appConfig) {
+async function loadAsyncEndpoints() {
let serviceKey, googleUserProvides;
+ const { googleKey } = config;
/** Check if GOOGLE_KEY is provided at all(including 'user_provided') */
const isGoogleKeyProvided = googleKey && googleKey.trim() !== '';
@@ -34,21 +28,7 @@ async function loadAsyncEndpoints(appConfig) {
const google = serviceKey || isGoogleKeyProvided ? { userProvide: googleUserProvides } : false;
- const useAzure = !!appConfig?.endpoints?.[EModelEndpoint.azureOpenAI]?.plugins;
- const gptPlugins =
- useAzure || openAIApiKey || azureOpenAIApiKey
- ? {
- availableAgents: ['classic', 'functions'],
- userProvide: useAzure ? false : userProvidedOpenAI,
- userProvideURL: useAzure
- ? false
- : config[EModelEndpoint.openAI]?.userProvideURL ||
- config[EModelEndpoint.azureOpenAI]?.userProvideURL,
- azure: useAzurePlugins || useAzure,
- }
- : false;
-
- return { google, gptPlugins };
+ return { google };
}
module.exports = loadAsyncEndpoints;
diff --git a/api/server/services/Config/loadConfigModels.js b/api/server/services/Config/loadConfigModels.js
index 34b6a1ecd2..2bc83ecc3a 100644
--- a/api/server/services/Config/loadConfigModels.js
+++ b/api/server/services/Config/loadConfigModels.js
@@ -1,10 +1,9 @@
-const { isUserProvided } = require('@librechat/api');
+const { isUserProvided, fetchModels } = require('@librechat/api');
const {
EModelEndpoint,
extractEnvVariable,
normalizeEndpointName,
} = require('librechat-data-provider');
-const { fetchModels } = require('~/server/services/ModelService');
const { getAppConfig } = require('./app');
/**
@@ -25,14 +24,15 @@ async function loadConfigModels(req) {
modelsConfig[EModelEndpoint.azureOpenAI] = modelNames;
}
- if (modelNames && azureConfig && azureConfig.plugins) {
- modelsConfig[EModelEndpoint.gptPlugins] = modelNames;
- }
-
if (azureConfig?.assistants && azureConfig.assistantModels) {
modelsConfig[EModelEndpoint.azureAssistants] = azureConfig.assistantModels;
}
+ const bedrockConfig = appConfig.endpoints?.[EModelEndpoint.bedrock];
+ if (bedrockConfig?.models && Array.isArray(bedrockConfig.models)) {
+ modelsConfig[EModelEndpoint.bedrock] = bedrockConfig.models;
+ }
+
if (!Array.isArray(appConfig.endpoints?.[EModelEndpoint.custom])) {
return modelsConfig;
}
diff --git a/api/server/services/Config/loadConfigModels.spec.js b/api/server/services/Config/loadConfigModels.spec.js
index 1e0e8780a7..6ffb8ba522 100644
--- a/api/server/services/Config/loadConfigModels.spec.js
+++ b/api/server/services/Config/loadConfigModels.spec.js
@@ -1,8 +1,11 @@
-const { fetchModels } = require('~/server/services/ModelService');
+const { fetchModels } = require('@librechat/api');
const loadConfigModels = require('./loadConfigModels');
const { getAppConfig } = require('./app');
-jest.mock('~/server/services/ModelService');
+jest.mock('@librechat/api', () => ({
+ ...jest.requireActual('@librechat/api'),
+ fetchModels: jest.fn(),
+}));
jest.mock('./app');
const exampleConfig = {
diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js
index c0415674b9..db25049957 100644
--- a/api/server/services/Config/loadCustomConfig.js
+++ b/api/server/services/Config/loadCustomConfig.js
@@ -85,26 +85,32 @@ Please specify a correct \`imageOutputType\` value (case-sensitive).
let errorMessage = `Invalid custom config file at ${configPath}:
${JSON.stringify(result.error, null, 2)}`;
- if (i === 0) {
- logger.error(errorMessage);
- const speechError = result.error.errors.find(
- (err) =>
- err.code === 'unrecognized_keys' &&
- (err.message?.includes('stt') || err.message?.includes('tts')),
- );
+ logger.error(errorMessage);
+ const speechError = result.error.errors.find(
+ (err) =>
+ err.code === 'unrecognized_keys' &&
+ (err.message?.includes('stt') || err.message?.includes('tts')),
+ );
- if (speechError) {
- logger.warn(`
+ if (speechError) {
+ logger.warn(`
The Speech-to-text and Text-to-speech configuration format has recently changed.
If you're getting this error, please refer to the latest documentation:
https://www.librechat.ai/docs/configuration/stt_tts`);
- }
-
- i++;
}
- return null;
+ if (process.env.CONFIG_BYPASS_VALIDATION === 'true') {
+ logger.warn(
+ 'CONFIG_BYPASS_VALIDATION is enabled. Continuing with default configuration despite validation errors.',
+ );
+ return null;
+ }
+
+ logger.error(
+ 'Exiting due to invalid configuration. Set CONFIG_BYPASS_VALIDATION=true to bypass this check.',
+ );
+ process.exit(1);
} else {
if (printConfig) {
logger.info('Custom config file loaded:');
diff --git a/api/server/services/Config/loadCustomConfig.spec.js b/api/server/services/Config/loadCustomConfig.spec.js
index 4f2006a053..f7f11dc8f6 100644
--- a/api/server/services/Config/loadCustomConfig.spec.js
+++ b/api/server/services/Config/loadCustomConfig.spec.js
@@ -50,8 +50,25 @@ const { logger } = require('@librechat/data-schemas');
const loadCustomConfig = require('./loadCustomConfig');
describe('loadCustomConfig', () => {
+ const originalExit = process.exit;
+ const mockExit = jest.fn((code) => {
+ throw new Error(`process.exit called with "${code}"`);
+ });
+
+ beforeAll(() => {
+ process.exit = mockExit;
+ });
+
+ afterAll(() => {
+ process.exit = originalExit;
+ });
+
beforeEach(() => {
jest.resetAllMocks();
+ // Re-apply the exit mock implementation after resetAllMocks
+ mockExit.mockImplementation((code) => {
+ throw new Error(`process.exit called with "${code}"`);
+ });
delete process.env.CONFIG_PATH;
});
@@ -94,20 +111,38 @@ describe('loadCustomConfig', () => {
it('should return null and log if config schema validation fails', async () => {
const invalidConfig = { invalidField: true };
process.env.CONFIG_PATH = 'invalidConfig.yaml';
+ process.env.CONFIG_BYPASS_VALIDATION = 'true';
loadYaml.mockReturnValueOnce(invalidConfig);
const result = await loadCustomConfig();
expect(result).toBeNull();
+ expect(logger.warn).toHaveBeenCalledWith(
+ 'CONFIG_BYPASS_VALIDATION is enabled. Continuing with default configuration despite validation errors.',
+ );
+ delete process.env.CONFIG_BYPASS_VALIDATION;
+ });
+
+ it('should call process.exit(1) when config validation fails without bypass', async () => {
+ const invalidConfig = { invalidField: true };
+ process.env.CONFIG_PATH = 'invalidConfig.yaml';
+ loadYaml.mockReturnValueOnce(invalidConfig);
+
+ await expect(loadCustomConfig()).rejects.toThrow('process.exit called with "1"');
+ expect(logger.error).toHaveBeenCalledWith(
+ 'Exiting due to invalid configuration. Set CONFIG_BYPASS_VALIDATION=true to bypass this check.',
+ );
});
it('should handle and return null on YAML parse error for a string response from remote', async () => {
process.env.CONFIG_PATH = 'http://example.com/config.yaml';
+ process.env.CONFIG_BYPASS_VALIDATION = 'true';
axios.get.mockResolvedValue({ data: 'invalidYAMLContent' });
const result = await loadCustomConfig();
expect(result).toBeNull();
+ delete process.env.CONFIG_BYPASS_VALIDATION;
});
it('should return the custom config object for a valid remote config file', async () => {
diff --git a/api/server/services/Config/loadDefaultEConfig.js b/api/server/services/Config/loadDefaultEConfig.js
index f3c12a4933..557b93ce8e 100644
--- a/api/server/services/Config/loadDefaultEConfig.js
+++ b/api/server/services/Config/loadDefaultEConfig.js
@@ -8,8 +8,8 @@ const { config } = require('./EndpointService');
* @returns {Promise>} An object whose keys are endpoint names and values are objects that contain the endpoint configuration and an order.
*/
async function loadDefaultEndpointsConfig(appConfig) {
- const { google, gptPlugins } = await loadAsyncEndpoints(appConfig);
- const { assistants, azureAssistants, azureOpenAI, chatGPTBrowser } = config;
+ const { google } = await loadAsyncEndpoints(appConfig);
+ const { assistants, azureAssistants, azureOpenAI } = config;
const enabledEndpoints = getEnabledEndpoints();
@@ -20,8 +20,6 @@ async function loadDefaultEndpointsConfig(appConfig) {
[EModelEndpoint.azureAssistants]: azureAssistants,
[EModelEndpoint.azureOpenAI]: azureOpenAI,
[EModelEndpoint.google]: google,
- [EModelEndpoint.chatGPTBrowser]: chatGPTBrowser,
- [EModelEndpoint.gptPlugins]: gptPlugins,
[EModelEndpoint.anthropic]: config[EModelEndpoint.anthropic],
[EModelEndpoint.bedrock]: config[EModelEndpoint.bedrock],
};
diff --git a/api/server/services/Config/loadDefaultModels.js b/api/server/services/Config/loadDefaultModels.js
index a70fa58495..31aa831a70 100644
--- a/api/server/services/Config/loadDefaultModels.js
+++ b/api/server/services/Config/loadDefaultModels.js
@@ -5,7 +5,8 @@ const {
getBedrockModels,
getOpenAIModels,
getGoogleModels,
-} = require('~/server/services/ModelService');
+} = require('@librechat/api');
+const { getAppConfig } = require('./app');
/**
* Loads the default models for the application.
@@ -15,16 +16,21 @@ const {
*/
async function loadDefaultModels(req) {
try {
+ const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role }));
+ const vertexConfig = appConfig?.endpoints?.[EModelEndpoint.anthropic]?.vertexConfig;
+
const [openAI, anthropic, azureOpenAI, assistants, azureAssistants, google, bedrock] =
await Promise.all([
getOpenAIModels({ user: req.user.id }).catch((error) => {
logger.error('Error fetching OpenAI models:', error);
return [];
}),
- getAnthropicModels({ user: req.user.id }).catch((error) => {
- logger.error('Error fetching Anthropic models:', error);
- return [];
- }),
+ getAnthropicModels({ user: req.user.id, vertexModels: vertexConfig?.modelNames }).catch(
+ (error) => {
+ logger.error('Error fetching Anthropic models:', error);
+ return [];
+ },
+ ),
getOpenAIModels({ user: req.user.id, azure: true }).catch((error) => {
logger.error('Error fetching Azure OpenAI models:', error);
return [];
diff --git a/api/server/services/Config/mcp.js b/api/server/services/Config/mcp.js
index 15ea62a028..cc4e98b59e 100644
--- a/api/server/services/Config/mcp.js
+++ b/api/server/services/Config/mcp.js
@@ -35,7 +35,7 @@ async function updateMCPServerTools({ userId, serverName, tools }) {
await setCachedTools(serverTools, { userId, serverName });
- const cache = getLogStores(CacheKeys.CONFIG_STORE);
+ const cache = getLogStores(CacheKeys.TOOL_CACHE);
await cache.delete(CacheKeys.TOOLS);
logger.debug(
`[MCP Cache] Updated ${tools.length} tools for server ${serverName} (user: ${userId})`,
@@ -61,7 +61,7 @@ async function mergeAppTools(appTools) {
const cachedTools = await getCachedTools();
const mergedTools = { ...cachedTools, ...appTools };
await setCachedTools(mergedTools);
- const cache = getLogStores(CacheKeys.CONFIG_STORE);
+ const cache = getLogStores(CacheKeys.TOOL_CACHE);
await cache.delete(CacheKeys.TOOLS);
logger.debug(`Merged ${count} app-level tools`);
} catch (error) {
diff --git a/api/server/services/Endpoints/agents/addedConvo.js b/api/server/services/Endpoints/agents/addedConvo.js
new file mode 100644
index 0000000000..25b1327991
--- /dev/null
+++ b/api/server/services/Endpoints/agents/addedConvo.js
@@ -0,0 +1,142 @@
+const { logger } = require('@librechat/data-schemas');
+const { initializeAgent, validateAgentModel } = require('@librechat/api');
+const { loadAddedAgent, setGetAgent, ADDED_AGENT_ID } = require('~/models/loadAddedAgent');
+const { getConvoFiles } = require('~/models/Conversation');
+const { getAgent } = require('~/models/Agent');
+const db = require('~/models');
+
+// Initialize the getAgent dependency
+setGetAgent(getAgent);
+
+/**
+ * Process addedConvo for parallel agent execution.
+ * Creates a parallel agent config from an added conversation.
+ *
+ * When an added agent has no incoming edges, it becomes a start node
+ * and runs in parallel with the primary agent automatically.
+ *
+ * Edge cases handled:
+ * - Primary agent has edges (handoffs): Added agent runs in parallel with primary,
+ * but doesn't participate in the primary's handoff graph
+ * - Primary agent has agent_ids (legacy chain): Added agent runs in parallel with primary,
+ * but doesn't participate in the chain
+ * - Primary agent has both: Added agent is independent, runs parallel from start
+ *
+ * @param {Object} params
+ * @param {import('express').Request} params.req
+ * @param {import('express').Response} params.res
+ * @param {Object} params.endpointOption - The endpoint option containing addedConvo
+ * @param {Object} params.modelsConfig - The models configuration
+ * @param {Function} params.logViolation - Function to log violations
+ * @param {Function} params.loadTools - Function to load agent tools
+ * @param {Array} params.requestFiles - Request files
+ * @param {string} params.conversationId - The conversation ID
+ * @param {string} [params.parentMessageId] - The parent message ID for thread filtering
+ * @param {Set} params.allowedProviders - Set of allowed providers
+ * @param {Map} params.agentConfigs - Map of agent configs to add to
+ * @param {string} params.primaryAgentId - The primary agent ID
+ * @param {Object|undefined} params.userMCPAuthMap - User MCP auth map to merge into
+ * @returns {Promise<{userMCPAuthMap: Object|undefined}>} The updated userMCPAuthMap
+ */
+const processAddedConvo = async ({
+ req,
+ res,
+ endpointOption,
+ modelsConfig,
+ logViolation,
+ loadTools,
+ requestFiles,
+ conversationId,
+ parentMessageId,
+ allowedProviders,
+ agentConfigs,
+ primaryAgentId,
+ primaryAgent,
+ userMCPAuthMap,
+}) => {
+ const addedConvo = endpointOption.addedConvo;
+ if (addedConvo == null) {
+ return { userMCPAuthMap };
+ }
+
+ logger.debug('[processAddedConvo] Processing added conversation', {
+ model: addedConvo.model,
+ agentId: addedConvo.agent_id,
+ endpoint: addedConvo.endpoint,
+ });
+
+ try {
+ const addedAgent = await loadAddedAgent({ req, conversation: addedConvo, primaryAgent });
+ if (!addedAgent) {
+ return { userMCPAuthMap };
+ }
+
+ const addedValidation = await validateAgentModel({
+ req,
+ res,
+ modelsConfig,
+ logViolation,
+ agent: addedAgent,
+ });
+
+ if (!addedValidation.isValid) {
+ logger.warn(
+ `[processAddedConvo] Added agent validation failed: ${addedValidation.error?.message}`,
+ );
+ return { userMCPAuthMap };
+ }
+
+ const addedConfig = await initializeAgent(
+ {
+ req,
+ res,
+ loadTools,
+ requestFiles,
+ conversationId,
+ parentMessageId,
+ agent: addedAgent,
+ endpointOption,
+ allowedProviders,
+ },
+ {
+ getConvoFiles,
+ getFiles: db.getFiles,
+ getUserKey: db.getUserKey,
+ getMessages: db.getMessages,
+ updateFilesUsage: db.updateFilesUsage,
+ getUserCodeFiles: db.getUserCodeFiles,
+ getUserKeyValues: db.getUserKeyValues,
+ getToolFilesByIds: db.getToolFilesByIds,
+ getCodeGeneratedFiles: db.getCodeGeneratedFiles,
+ },
+ );
+
+ if (userMCPAuthMap != null) {
+ Object.assign(userMCPAuthMap, addedConfig.userMCPAuthMap ?? {});
+ } else {
+ userMCPAuthMap = addedConfig.userMCPAuthMap;
+ }
+
+ const addedAgentId = addedConfig.id || ADDED_AGENT_ID;
+ agentConfigs.set(addedAgentId, addedConfig);
+
+ // No edges needed - agent without incoming edges becomes a start node
+ // and runs in parallel with the primary agent automatically.
+ // This is independent of any edges/agent_ids the primary agent has.
+
+ logger.debug(
+ `[processAddedConvo] Added parallel agent: ${addedAgentId} (primary: ${primaryAgentId}, ` +
+ `primary has edges: ${!!endpointOption.edges}, primary has agent_ids: ${!!endpointOption.agent_ids})`,
+ );
+
+ return { userMCPAuthMap };
+ } catch (err) {
+ logger.error('[processAddedConvo] Error processing addedConvo for parallel agent', err);
+ return { userMCPAuthMap };
+ }
+};
+
+module.exports = {
+ processAddedConvo,
+ ADDED_AGENT_ID,
+};
diff --git a/api/server/services/Endpoints/agents/agent.js b/api/server/services/Endpoints/agents/agent.js
deleted file mode 100644
index eebaa2cfc0..0000000000
--- a/api/server/services/Endpoints/agents/agent.js
+++ /dev/null
@@ -1,226 +0,0 @@
-const { Providers } = require('@librechat/agents');
-const {
- primeResources,
- getModelMaxTokens,
- extractLibreChatParams,
- filterFilesByEndpointConfig,
- optionalChainWithEmptyCheck,
-} = require('@librechat/api');
-const {
- ErrorTypes,
- EModelEndpoint,
- EToolResources,
- paramEndpoints,
- isAgentsEndpoint,
- replaceSpecialVars,
- providerEndpointMap,
-} = require('librechat-data-provider');
-const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
-const { getProviderConfig } = require('~/server/services/Endpoints');
-const { processFiles } = require('~/server/services/Files/process');
-const { getFiles, getToolFilesByIds } = require('~/models/File');
-const { getConvoFiles } = require('~/models/Conversation');
-
-/**
- * @param {object} params
- * @param {ServerRequest} params.req
- * @param {ServerResponse} params.res
- * @param {Agent} params.agent
- * @param {string | null} [params.conversationId]
- * @param {Array} [params.requestFiles]
- * @param {typeof import('~/server/services/ToolService').loadAgentTools | undefined} [params.loadTools]
- * @param {TEndpointOption} [params.endpointOption]
- * @param {Set} [params.allowedProviders]
- * @param {boolean} [params.isInitialAgent]
- * @returns {Promise,
- * toolContextMap: Record,
- * maxContextTokens: number,
- * userMCPAuthMap?: Record>
- * }>}
- */
-const initializeAgent = async ({
- req,
- res,
- agent,
- loadTools,
- requestFiles,
- conversationId,
- endpointOption,
- allowedProviders,
- isInitialAgent = false,
-}) => {
- const appConfig = req.config;
- if (
- isAgentsEndpoint(endpointOption?.endpoint) &&
- allowedProviders.size > 0 &&
- !allowedProviders.has(agent.provider)
- ) {
- throw new Error(
- `{ "type": "${ErrorTypes.INVALID_AGENT_PROVIDER}", "info": "${agent.provider}" }`,
- );
- }
- let currentFiles;
-
- const _modelOptions = structuredClone(
- Object.assign(
- { model: agent.model },
- agent.model_parameters ?? { model: agent.model },
- isInitialAgent === true ? endpointOption?.model_parameters : {},
- ),
- );
-
- const { resendFiles, maxContextTokens, modelOptions } = extractLibreChatParams(_modelOptions);
-
- const provider = agent.provider;
- agent.endpoint = provider;
-
- if (isInitialAgent && conversationId != null && resendFiles) {
- const fileIds = (await getConvoFiles(conversationId)) ?? [];
- /** @type {Set} */
- const toolResourceSet = new Set();
- for (const tool of agent.tools) {
- if (EToolResources[tool]) {
- toolResourceSet.add(EToolResources[tool]);
- }
- }
- const toolFiles = await getToolFilesByIds(fileIds, toolResourceSet);
- if (requestFiles.length || toolFiles.length) {
- currentFiles = await processFiles(requestFiles.concat(toolFiles));
- }
- } else if (isInitialAgent && requestFiles.length) {
- currentFiles = await processFiles(requestFiles);
- }
-
- if (currentFiles && currentFiles.length) {
- let endpointType;
- if (!paramEndpoints.has(agent.endpoint)) {
- endpointType = EModelEndpoint.custom;
- }
-
- currentFiles = filterFilesByEndpointConfig(req, {
- files: currentFiles,
- endpoint: agent.endpoint,
- endpointType,
- });
- }
-
- const { attachments, tool_resources } = await primeResources({
- req,
- getFiles,
- appConfig,
- agentId: agent.id,
- attachments: currentFiles,
- tool_resources: agent.tool_resources,
- requestFileSet: new Set(requestFiles?.map((file) => file.file_id)),
- });
-
- const {
- tools: structuredTools,
- toolContextMap,
- userMCPAuthMap,
- } = (await loadTools?.({
- req,
- res,
- provider,
- agentId: agent.id,
- tools: agent.tools,
- model: agent.model,
- tool_resources,
- })) ?? {};
-
- const { getOptions, overrideProvider } = getProviderConfig({ provider, appConfig });
- if (overrideProvider !== agent.provider) {
- agent.provider = overrideProvider;
- }
-
- const _endpointOption =
- isInitialAgent === true
- ? Object.assign({}, endpointOption, { model_parameters: modelOptions })
- : { model_parameters: modelOptions };
-
- const options = await getOptions({
- req,
- res,
- optionsOnly: true,
- overrideEndpoint: provider,
- overrideModel: agent.model,
- endpointOption: _endpointOption,
- });
-
- const tokensModel =
- agent.provider === EModelEndpoint.azureOpenAI ? agent.model : options.llmConfig?.model;
- const maxOutputTokens = optionalChainWithEmptyCheck(
- options.llmConfig?.maxOutputTokens,
- options.llmConfig?.maxTokens,
- 0,
- );
- const agentMaxContextTokens = optionalChainWithEmptyCheck(
- maxContextTokens,
- getModelMaxTokens(tokensModel, providerEndpointMap[provider], options.endpointTokenConfig),
- 18000,
- );
-
- if (
- agent.endpoint === EModelEndpoint.azureOpenAI &&
- options.llmConfig?.azureOpenAIApiInstanceName == null
- ) {
- agent.provider = Providers.OPENAI;
- }
-
- if (options.provider != null) {
- agent.provider = options.provider;
- }
-
- /** @type {import('@librechat/agents').GenericTool[]} */
- let tools = options.tools?.length ? options.tools : structuredTools;
- if (
- (agent.provider === Providers.GOOGLE || agent.provider === Providers.VERTEXAI) &&
- options.tools?.length &&
- structuredTools?.length
- ) {
- throw new Error(`{ "type": "${ErrorTypes.GOOGLE_TOOL_CONFLICT}"}`);
- } else if (
- (agent.provider === Providers.OPENAI ||
- agent.provider === Providers.AZURE ||
- agent.provider === Providers.ANTHROPIC) &&
- options.tools?.length &&
- structuredTools?.length
- ) {
- tools = structuredTools.concat(options.tools);
- }
-
- /** @type {import('@librechat/agents').ClientOptions} */
- agent.model_parameters = { ...options.llmConfig };
- if (options.configOptions) {
- agent.model_parameters.configuration = options.configOptions;
- }
-
- if (agent.instructions && agent.instructions !== '') {
- agent.instructions = replaceSpecialVars({
- text: agent.instructions,
- user: req.user,
- });
- }
-
- if (typeof agent.artifacts === 'string' && agent.artifacts !== '') {
- agent.additional_instructions = generateArtifactsPrompt({
- endpoint: agent.provider,
- artifacts: agent.artifacts,
- });
- }
-
- return {
- ...agent,
- tools,
- attachments,
- resendFiles,
- userMCPAuthMap,
- toolContextMap,
- useLegacyContent: !!options.useLegacyContent,
- maxContextTokens: Math.round((agentMaxContextTokens - maxOutputTokens) * 0.9),
- };
-};
-
-module.exports = { initializeAgent };
diff --git a/api/server/services/Endpoints/agents/build.js b/api/server/services/Endpoints/agents/build.js
index 34fcaf4be4..a95640e528 100644
--- a/api/server/services/Endpoints/agents/build.js
+++ b/api/server/services/Endpoints/agents/build.js
@@ -15,6 +15,9 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => {
return undefined;
});
+ /** @type {import('librechat-data-provider').TConversation | undefined} */
+ const addedConvo = req.body?.addedConvo;
+
return removeNullishValues({
spec,
iconURL,
@@ -23,6 +26,7 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => {
endpointType,
model_parameters,
agent: agentPromise,
+ addedConvo,
});
};
diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js
index 3064a03662..e71270ef85 100644
--- a/api/server/services/Endpoints/agents/initialize.js
+++ b/api/server/services/Endpoints/agents/initialize.js
@@ -1,31 +1,41 @@
const { logger } = require('@librechat/data-schemas');
const { createContentAggregator } = require('@librechat/agents');
const {
+ initializeAgent,
validateAgentModel,
+ createEdgeCollector,
+ filterOrphanedEdges,
+ GenerationJobManager,
getCustomEndpointConfig,
createSequentialChainEdges,
} = require('@librechat/api');
const {
- Constants,
EModelEndpoint,
isAgentsEndpoint,
getResponseSender,
+ isEphemeralAgentId,
} = require('librechat-data-provider');
const {
createToolEndCallback,
getDefaultHandlers,
} = require('~/server/controllers/agents/callbacks');
-const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
+const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService');
const { getModelsConfig } = require('~/server/controllers/ModelController');
-const { loadAgentTools } = require('~/server/services/ToolService');
const AgentClient = require('~/server/controllers/agents/client');
+const { getConvoFiles } = require('~/models/Conversation');
+const { processAddedConvo } = require('./addedConvo');
const { getAgent } = require('~/models/Agent');
const { logViolation } = require('~/cache');
+const db = require('~/models');
/**
- * @param {AbortSignal} signal
+ * Creates a tool loader function for the agent.
+ * @param {AbortSignal} signal - The abort signal
+ * @param {string | null} [streamId] - The stream ID for resumable mode
+ * @param {boolean} [definitionsOnly=false] - When true, returns only serializable
+ * tool definitions without creating full tool instances (for event-driven mode)
*/
-function createToolLoader(signal) {
+function createToolLoader(signal, streamId = null, definitionsOnly = false) {
/**
* @param {object} params
* @param {ServerRequest} params.req
@@ -36,20 +46,33 @@ function createToolLoader(signal) {
* @param {string} params.model
* @param {AgentToolResources} params.tool_resources
* @returns {Promise<{
- * tools: StructuredTool[],
- * toolContextMap: Record,
- * userMCPAuthMap?: Record>
+ * tools?: StructuredTool[],
+ * toolContextMap: Record,
+ * toolDefinitions?: import('@librechat/agents').LCTool[],
+ * userMCPAuthMap?: Record>,
+ * toolRegistry?: import('@librechat/agents').LCToolRegistry
* } | undefined>}
*/
- return async function loadTools({ req, res, agentId, tools, provider, model, tool_resources }) {
- const agent = { id: agentId, tools, provider, model };
+ return async function loadTools({
+ req,
+ res,
+ tools,
+ model,
+ agentId,
+ provider,
+ tool_options,
+ tool_resources,
+ }) {
+ const agent = { id: agentId, tools, provider, model, tool_options };
try {
return await loadAgentTools({
req,
res,
agent,
signal,
+ streamId,
tool_resources,
+ definitionsOnly,
});
} catch (error) {
logger.error('Error loading tools for agent ' + agentId, error);
@@ -63,18 +86,60 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
}
const appConfig = req.config;
- // TODO: use endpointOption to determine options/modelOptions
+ /** @type {string | null} */
+ const streamId = req._resumableStreamId || null;
+
/** @type {Array} */
const collectedUsage = [];
/** @type {ArtifactPromises} */
const artifactPromises = [];
const { contentParts, aggregateContent } = createContentAggregator();
- const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
+ const toolEndCallback = createToolEndCallback({ req, res, artifactPromises, streamId });
+
+ /**
+ * Agent context store - populated after initialization, accessed by callback via closure.
+ * Maps agentId -> { userMCPAuthMap, agent, tool_resources, toolRegistry, openAIApiKey }
+ * @type {Map>,
+ * agent?: object,
+ * tool_resources?: object,
+ * toolRegistry?: import('@librechat/agents').LCToolRegistry,
+ * openAIApiKey?: string
+ * }>}
+ */
+ const agentToolContexts = new Map();
+
+ const toolExecuteOptions = {
+ loadTools: async (toolNames, agentId) => {
+ const ctx = agentToolContexts.get(agentId) ?? {};
+ logger.debug(`[ON_TOOL_EXECUTE] ctx found: ${!!ctx.userMCPAuthMap}, agent: ${ctx.agent?.id}`);
+ logger.debug(`[ON_TOOL_EXECUTE] toolRegistry size: ${ctx.toolRegistry?.size ?? 'undefined'}`);
+
+ const result = await loadToolsForExecution({
+ req,
+ res,
+ signal,
+ streamId,
+ toolNames,
+ agent: ctx.agent,
+ toolRegistry: ctx.toolRegistry,
+ userMCPAuthMap: ctx.userMCPAuthMap,
+ tool_resources: ctx.tool_resources,
+ });
+
+ logger.debug(`[ON_TOOL_EXECUTE] loaded ${result.loadedTools?.length ?? 0} tools`);
+ return result;
+ },
+ toolEndCallback,
+ };
+
const eventHandlers = getDefaultHandlers({
res,
+ toolExecuteOptions,
aggregateContent,
toolEndCallback,
collectedUsage,
+ streamId,
});
if (!endpointOption.agent) {
@@ -103,31 +168,65 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
const agentConfigs = new Map();
const allowedProviders = new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.allowedProviders);
- const loadTools = createToolLoader(signal);
+ /** Event-driven mode: only load tool definitions, not full instances */
+ const loadTools = createToolLoader(signal, streamId, true);
/** @type {Array} */
const requestFiles = req.body.files ?? [];
/** @type {string} */
const conversationId = req.body.conversationId;
+ /** @type {string | undefined} */
+ const parentMessageId = req.body.parentMessageId;
- const primaryConfig = await initializeAgent({
- req,
- res,
- loadTools,
- requestFiles,
- conversationId,
+ const primaryConfig = await initializeAgent(
+ {
+ req,
+ res,
+ loadTools,
+ requestFiles,
+ conversationId,
+ parentMessageId,
+ agent: primaryAgent,
+ endpointOption,
+ allowedProviders,
+ isInitialAgent: true,
+ },
+ {
+ getConvoFiles,
+ getFiles: db.getFiles,
+ getUserKey: db.getUserKey,
+ getMessages: db.getMessages,
+ updateFilesUsage: db.updateFilesUsage,
+ getUserKeyValues: db.getUserKeyValues,
+ getUserCodeFiles: db.getUserCodeFiles,
+ getToolFilesByIds: db.getToolFilesByIds,
+ getCodeGeneratedFiles: db.getCodeGeneratedFiles,
+ },
+ );
+
+ logger.debug(
+ `[initializeClient] Storing tool context for ${primaryConfig.id}: ${primaryConfig.toolDefinitions?.length ?? 0} tools, registry size: ${primaryConfig.toolRegistry?.size ?? '0'}`,
+ );
+ agentToolContexts.set(primaryConfig.id, {
agent: primaryAgent,
- endpointOption,
- allowedProviders,
- isInitialAgent: true,
+ toolRegistry: primaryConfig.toolRegistry,
+ userMCPAuthMap: primaryConfig.userMCPAuthMap,
+ tool_resources: primaryConfig.tool_resources,
});
const agent_ids = primaryConfig.agent_ids;
let userMCPAuthMap = primaryConfig.userMCPAuthMap;
+ /** @type {Set} Track agents that failed to load (orphaned references) */
+ const skippedAgentIds = new Set();
+
async function processAgent(agentId) {
const agent = await getAgent({ id: agentId });
if (!agent) {
- throw new Error(`Agent ${agentId} not found`);
+ logger.warn(
+ `[processAgent] Handoff agent ${agentId} not found, skipping (orphaned reference)`,
+ );
+ skippedAgentIds.add(agentId);
+ return null;
}
const validationResult = await validateAgentModel({
@@ -142,53 +241,72 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
throw new Error(validationResult.error?.message);
}
- const config = await initializeAgent({
- req,
- res,
- agent,
- loadTools,
- requestFiles,
- conversationId,
- endpointOption,
- allowedProviders,
- });
+ const config = await initializeAgent(
+ {
+ req,
+ res,
+ agent,
+ loadTools,
+ requestFiles,
+ conversationId,
+ parentMessageId,
+ endpointOption,
+ allowedProviders,
+ },
+ {
+ getConvoFiles,
+ getFiles: db.getFiles,
+ getUserKey: db.getUserKey,
+ getMessages: db.getMessages,
+ updateFilesUsage: db.updateFilesUsage,
+ getUserKeyValues: db.getUserKeyValues,
+ getUserCodeFiles: db.getUserCodeFiles,
+ getToolFilesByIds: db.getToolFilesByIds,
+ getCodeGeneratedFiles: db.getCodeGeneratedFiles,
+ },
+ );
+
if (userMCPAuthMap != null) {
Object.assign(userMCPAuthMap, config.userMCPAuthMap ?? {});
} else {
userMCPAuthMap = config.userMCPAuthMap;
}
+
+ /** Store handoff agent's tool context for ON_TOOL_EXECUTE callback */
+ agentToolContexts.set(agentId, {
+ agent,
+ toolRegistry: config.toolRegistry,
+ userMCPAuthMap: config.userMCPAuthMap,
+ tool_resources: config.tool_resources,
+ });
+
agentConfigs.set(agentId, config);
+ return agent;
}
- let edges = primaryConfig.edges;
const checkAgentInit = (agentId) => agentId === primaryConfig.id || agentConfigs.has(agentId);
- if ((edges?.length ?? 0) > 0) {
- for (const edge of edges) {
- if (Array.isArray(edge.to)) {
- for (const to of edge.to) {
- if (checkAgentInit(to)) {
- continue;
- }
- await processAgent(to);
- }
- } else if (typeof edge.to === 'string' && checkAgentInit(edge.to)) {
- continue;
- } else if (typeof edge.to === 'string') {
- await processAgent(edge.to);
- }
- if (Array.isArray(edge.from)) {
- for (const from of edge.from) {
- if (checkAgentInit(from)) {
- continue;
- }
- await processAgent(from);
- }
- } else if (typeof edge.from === 'string' && checkAgentInit(edge.from)) {
- continue;
- } else if (typeof edge.from === 'string') {
- await processAgent(edge.from);
+ // Graph topology discovery for recursive agent handoffs (BFS)
+ const { edgeMap, agentsToProcess, collectEdges } = createEdgeCollector(
+ checkAgentInit,
+ skippedAgentIds,
+ );
+
+ // Seed with primary agent's edges
+ collectEdges(primaryConfig.edges);
+
+ // BFS to load and merge all connected agents (enables transitive handoffs: A->B->C)
+ while (agentsToProcess.size > 0) {
+ const agentId = agentsToProcess.values().next().value;
+ agentsToProcess.delete(agentId);
+ try {
+ const agent = await processAgent(agentId);
+ if (agent?.edges?.length) {
+ collectEdges(agent.edges);
}
+ } catch (err) {
+ logger.error(`[initializeClient] Error processing agent ${agentId}:`, err);
+ skippedAgentIds.add(agentId);
}
}
@@ -198,13 +316,50 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
if (checkAgentInit(agentId)) {
continue;
}
- await processAgent(agentId);
+ try {
+ await processAgent(agentId);
+ } catch (err) {
+ logger.error(`[initializeClient] Error processing chain agent ${agentId}:`, err);
+ skippedAgentIds.add(agentId);
+ }
}
-
const chain = await createSequentialChainEdges([primaryConfig.id].concat(agent_ids), '{convo}');
- edges = edges ? edges.concat(chain) : chain;
+ collectEdges(chain);
}
+ let edges = Array.from(edgeMap.values());
+
+ /** Multi-Convo: Process addedConvo for parallel agent execution */
+ const { userMCPAuthMap: updatedMCPAuthMap } = await processAddedConvo({
+ req,
+ res,
+ loadTools,
+ logViolation,
+ modelsConfig,
+ requestFiles,
+ agentConfigs,
+ primaryAgent,
+ endpointOption,
+ userMCPAuthMap,
+ conversationId,
+ parentMessageId,
+ allowedProviders,
+ primaryAgentId: primaryConfig.id,
+ });
+
+ if (updatedMCPAuthMap) {
+ userMCPAuthMap = updatedMCPAuthMap;
+ }
+
+ // Ensure edges is an array when we have multiple agents (multi-agent mode)
+ // MultiAgentGraph.categorizeEdges requires edges to be iterable
+ if (agentConfigs.size > 0 && !edges) {
+ edges = [];
+ }
+
+ // Filter out edges referencing non-existent agents (orphaned references)
+ edges = filterOrphanedEdges(edges, skippedAgentIds);
+
primaryConfig.edges = edges;
let endpointConfig = appConfig.endpoints?.[primaryConfig.endpoint];
@@ -248,12 +403,13 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
endpointType: endpointOption.endpointType,
resendFiles: primaryConfig.resendFiles ?? true,
maxContextTokens: primaryConfig.maxContextTokens,
- endpoint:
- primaryConfig.id === Constants.EPHEMERAL_AGENT_ID
- ? primaryConfig.endpoint
- : EModelEndpoint.agents,
+ endpoint: isEphemeralAgentId(primaryConfig.id) ? primaryConfig.endpoint : EModelEndpoint.agents,
});
+ if (streamId) {
+ GenerationJobManager.setCollectedUsage(streamId, collectedUsage);
+ }
+
return { client, userMCPAuthMap };
};
diff --git a/api/server/services/Endpoints/agents/title.js b/api/server/services/Endpoints/agents/title.js
index 74cdc0b2c2..e31cdeea11 100644
--- a/api/server/services/Endpoints/agents/title.js
+++ b/api/server/services/Endpoints/agents/title.js
@@ -17,6 +17,11 @@ const addTitle = async (req, { text, response, client }) => {
return;
}
+ // Skip title generation for temporary conversations
+ if (req?.body?.isTemporary) {
+ return;
+ }
+
const titleCache = getLogStores(CacheKeys.GEN_TITLE);
const key = `${req.user.id}-${response.conversationId}`;
/** @type {NodeJS.Timeout} */
@@ -66,7 +71,7 @@ const addTitle = async (req, { text, response, client }) => {
conversationId: response.conversationId,
title,
},
- { context: 'api/server/services/Endpoints/agents/title.js' },
+ { context: 'api/server/services/Endpoints/agents/title.js', noUpsert: true },
);
} catch (error) {
logger.error('Error generating title:', error);
diff --git a/api/server/services/Endpoints/anthropic/build.js b/api/server/services/Endpoints/anthropic/build.js
deleted file mode 100644
index 1d2c29d81e..0000000000
--- a/api/server/services/Endpoints/anthropic/build.js
+++ /dev/null
@@ -1,44 +0,0 @@
-const { removeNullishValues, anthropicSettings } = require('librechat-data-provider');
-const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
-
-const buildOptions = (endpoint, parsedBody) => {
- const {
- modelLabel,
- promptPrefix,
- maxContextTokens,
- fileTokenLimit,
- resendFiles = anthropicSettings.resendFiles.default,
- promptCache = anthropicSettings.promptCache.default,
- thinking = anthropicSettings.thinking.default,
- thinkingBudget = anthropicSettings.thinkingBudget.default,
- iconURL,
- greeting,
- spec,
- artifacts,
- ...modelOptions
- } = parsedBody;
-
- const endpointOption = removeNullishValues({
- endpoint,
- modelLabel,
- promptPrefix,
- resendFiles,
- promptCache,
- thinking,
- thinkingBudget,
- iconURL,
- greeting,
- spec,
- maxContextTokens,
- fileTokenLimit,
- modelOptions,
- });
-
- if (typeof artifacts === 'string') {
- endpointOption.artifactsPrompt = generateArtifactsPrompt({ endpoint, artifacts });
- }
-
- return endpointOption;
-};
-
-module.exports = buildOptions;
diff --git a/api/server/services/Endpoints/anthropic/index.js b/api/server/services/Endpoints/anthropic/index.js
deleted file mode 100644
index c4e7533c5d..0000000000
--- a/api/server/services/Endpoints/anthropic/index.js
+++ /dev/null
@@ -1,9 +0,0 @@
-const addTitle = require('./title');
-const buildOptions = require('./build');
-const initializeClient = require('./initialize');
-
-module.exports = {
- addTitle,
- buildOptions,
- initializeClient,
-};
diff --git a/api/server/services/Endpoints/anthropic/initialize.js b/api/server/services/Endpoints/anthropic/initialize.js
deleted file mode 100644
index 88639b3d7c..0000000000
--- a/api/server/services/Endpoints/anthropic/initialize.js
+++ /dev/null
@@ -1,70 +0,0 @@
-const { getLLMConfig } = require('@librechat/api');
-const { EModelEndpoint } = require('librechat-data-provider');
-const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService');
-const AnthropicClient = require('~/app/clients/AnthropicClient');
-
-const initializeClient = async ({ req, res, endpointOption, overrideModel, optionsOnly }) => {
- const appConfig = req.config;
- const { ANTHROPIC_API_KEY, ANTHROPIC_REVERSE_PROXY, PROXY } = process.env;
- const expiresAt = req.body.key;
- const isUserProvided = ANTHROPIC_API_KEY === 'user_provided';
-
- const anthropicApiKey = isUserProvided
- ? await getUserKey({ userId: req.user.id, name: EModelEndpoint.anthropic })
- : ANTHROPIC_API_KEY;
-
- if (!anthropicApiKey) {
- throw new Error('Anthropic API key not provided. Please provide it again.');
- }
-
- if (expiresAt && isUserProvided) {
- checkUserKeyExpiry(expiresAt, EModelEndpoint.anthropic);
- }
-
- let clientOptions = {};
-
- /** @type {undefined | TBaseEndpoint} */
- const anthropicConfig = appConfig.endpoints?.[EModelEndpoint.anthropic];
-
- if (anthropicConfig) {
- clientOptions._lc_stream_delay = anthropicConfig.streamRate;
- clientOptions.titleModel = anthropicConfig.titleModel;
- }
-
- const allConfig = appConfig.endpoints?.all;
- if (allConfig) {
- clientOptions._lc_stream_delay = allConfig.streamRate;
- }
-
- if (optionsOnly) {
- clientOptions = Object.assign(
- {
- proxy: PROXY ?? null,
- reverseProxyUrl: ANTHROPIC_REVERSE_PROXY ?? null,
- modelOptions: endpointOption?.model_parameters ?? {},
- },
- clientOptions,
- );
- if (overrideModel) {
- clientOptions.modelOptions.model = overrideModel;
- }
- clientOptions.modelOptions.user = req.user.id;
- return getLLMConfig(anthropicApiKey, clientOptions);
- }
-
- const client = new AnthropicClient(anthropicApiKey, {
- req,
- res,
- reverseProxyUrl: ANTHROPIC_REVERSE_PROXY ?? null,
- proxy: PROXY ?? null,
- ...clientOptions,
- ...endpointOption,
- });
-
- return {
- client,
- anthropicApiKey,
- };
-};
-
-module.exports = initializeClient;
diff --git a/api/server/services/Endpoints/anthropic/title.js b/api/server/services/Endpoints/anthropic/title.js
deleted file mode 100644
index cac39fa2be..0000000000
--- a/api/server/services/Endpoints/anthropic/title.js
+++ /dev/null
@@ -1,35 +0,0 @@
-const { isEnabled } = require('@librechat/api');
-const { CacheKeys } = require('librechat-data-provider');
-const getLogStores = require('~/cache/getLogStores');
-const { saveConvo } = require('~/models');
-
-const addTitle = async (req, { text, response, client }) => {
- const { TITLE_CONVO = 'true' } = process.env ?? {};
- if (!isEnabled(TITLE_CONVO)) {
- return;
- }
-
- if (client.options.titleConvo === false) {
- return;
- }
-
- const titleCache = getLogStores(CacheKeys.GEN_TITLE);
- const key = `${req.user.id}-${response.conversationId}`;
-
- const title = await client.titleConvo({
- text,
- responseText: response?.text ?? '',
- conversationId: response.conversationId,
- });
- await titleCache.set(key, title, 120000);
- await saveConvo(
- req,
- {
- conversationId: response.conversationId,
- title,
- },
- { context: 'api/server/services/Endpoints/anthropic/addTitle.js' },
- );
-};
-
-module.exports = addTitle;
diff --git a/api/server/services/Endpoints/assistants/initalize.js b/api/server/services/Endpoints/assistants/initalize.js
index 9ef5228e34..d5a246dff7 100644
--- a/api/server/services/Endpoints/assistants/initalize.js
+++ b/api/server/services/Endpoints/assistants/initalize.js
@@ -1,15 +1,10 @@
const OpenAI = require('openai');
const { ProxyAgent } = require('undici');
+const { isUserProvided, checkUserKeyExpiry } = require('@librechat/api');
const { ErrorTypes, EModelEndpoint } = require('librechat-data-provider');
-const {
- getUserKeyValues,
- getUserKeyExpiry,
- checkUserKeyExpiry,
-} = require('~/server/services/UserService');
-const OAIClient = require('~/app/clients/OpenAIClient');
-const { isUserProvided } = require('~/server/utils');
+const { getUserKeyValues, getUserKeyExpiry } = require('~/models');
-const initializeClient = async ({ req, res, endpointOption, version, initAppClient = false }) => {
+const initializeClient = async ({ req, res, version }) => {
const { PROXY, OPENAI_ORGANIZATION, ASSISTANTS_API_KEY, ASSISTANTS_BASE_URL } = process.env;
const userProvidesKey = isUserProvided(ASSISTANTS_API_KEY);
@@ -34,14 +29,6 @@ const initializeClient = async ({ req, res, endpointOption, version, initAppClie
},
};
- const clientOptions = {
- reverseProxyUrl: baseURL ?? null,
- proxy: PROXY ?? null,
- req,
- res,
- ...endpointOption,
- };
-
if (userProvidesKey & !apiKey) {
throw new Error(
JSON.stringify({
@@ -78,15 +65,6 @@ const initializeClient = async ({ req, res, endpointOption, version, initAppClie
openai.req = req;
openai.res = res;
- if (endpointOption && initAppClient) {
- const client = new OAIClient(apiKey, clientOptions);
- return {
- client,
- openai,
- openAIApiKey: apiKey,
- };
- }
-
return {
openai,
openAIApiKey: apiKey,
diff --git a/api/server/services/Endpoints/assistants/initialize.spec.js b/api/server/services/Endpoints/assistants/initialize.spec.js
deleted file mode 100644
index 3a870dc61d..0000000000
--- a/api/server/services/Endpoints/assistants/initialize.spec.js
+++ /dev/null
@@ -1,113 +0,0 @@
-// const OpenAI = require('openai');
-const { ProxyAgent } = require('undici');
-const { ErrorTypes } = require('librechat-data-provider');
-const { getUserKey, getUserKeyExpiry, getUserKeyValues } = require('~/server/services/UserService');
-const initializeClient = require('./initalize');
-// const { OpenAIClient } = require('~/app');
-
-jest.mock('~/server/services/UserService', () => ({
- getUserKey: jest.fn(),
- getUserKeyExpiry: jest.fn(),
- getUserKeyValues: jest.fn(),
- checkUserKeyExpiry: jest.requireActual('~/server/services/UserService').checkUserKeyExpiry,
-}));
-
-const today = new Date();
-const tenDaysFromToday = new Date(today.setDate(today.getDate() + 10));
-const isoString = tenDaysFromToday.toISOString();
-
-describe('initializeClient', () => {
- // Set up environment variables
- const originalEnvironment = process.env;
- const app = {
- locals: {},
- };
-
- beforeEach(() => {
- jest.resetModules(); // Clears the cache
- process.env = { ...originalEnvironment }; // Make a copy
- });
-
- afterAll(() => {
- process.env = originalEnvironment; // Restore original env vars
- });
-
- test('initializes OpenAI client with default API key and URL', async () => {
- process.env.ASSISTANTS_API_KEY = 'default-api-key';
- process.env.ASSISTANTS_BASE_URL = 'https://default.api.url';
-
- // Assuming 'isUserProvided' to return false for this test case
- jest.mock('~/server/utils', () => ({
- isUserProvided: jest.fn().mockReturnValueOnce(false),
- }));
-
- const req = { user: { id: 'user123' }, app };
- const res = {};
-
- const { openai, openAIApiKey } = await initializeClient({ req, res });
- expect(openai.apiKey).toBe('default-api-key');
- expect(openAIApiKey).toBe('default-api-key');
- expect(openai.baseURL).toBe('https://default.api.url');
- });
-
- test('initializes OpenAI client with user-provided API key and URL', async () => {
- process.env.ASSISTANTS_API_KEY = 'user_provided';
- process.env.ASSISTANTS_BASE_URL = 'user_provided';
-
- getUserKeyValues.mockResolvedValue({ apiKey: 'user-api-key', baseURL: 'https://user.api.url' });
- getUserKeyExpiry.mockResolvedValue(isoString);
-
- const req = { user: { id: 'user123' }, app };
- const res = {};
-
- const { openai, openAIApiKey } = await initializeClient({ req, res });
- expect(openAIApiKey).toBe('user-api-key');
- expect(openai.apiKey).toBe('user-api-key');
- expect(openai.baseURL).toBe('https://user.api.url');
- });
-
- test('throws error for invalid JSON in user-provided values', async () => {
- process.env.ASSISTANTS_API_KEY = 'user_provided';
- getUserKey.mockResolvedValue('invalid-json');
- getUserKeyExpiry.mockResolvedValue(isoString);
- getUserKeyValues.mockImplementation(() => {
- let userValues = getUserKey();
- try {
- userValues = JSON.parse(userValues);
- } catch (e) {
- throw new Error(
- JSON.stringify({
- type: ErrorTypes.INVALID_USER_KEY,
- }),
- );
- }
- return userValues;
- });
-
- const req = { user: { id: 'user123' } };
- const res = {};
-
- await expect(initializeClient({ req, res })).rejects.toThrow(/invalid_user_key/);
- });
-
- test('throws error if API key is not provided', async () => {
- delete process.env.ASSISTANTS_API_KEY; // Simulate missing API key
-
- const req = { user: { id: 'user123' }, app };
- const res = {};
-
- await expect(initializeClient({ req, res })).rejects.toThrow(/Assistants API key not/);
- });
-
- test('initializes OpenAI client with proxy configuration', async () => {
- process.env.ASSISTANTS_API_KEY = 'test-key';
- process.env.PROXY = 'http://proxy.server';
-
- const req = { user: { id: 'user123' }, app };
- const res = {};
-
- const { openai } = await initializeClient({ req, res });
- expect(openai.fetchOptions).toBeDefined();
- expect(openai.fetchOptions.dispatcher).toBeInstanceOf(ProxyAgent);
- });
-});
diff --git a/api/server/services/Endpoints/assistants/title.js b/api/server/services/Endpoints/assistants/title.js
index 223d3badc6..1fae68cf54 100644
--- a/api/server/services/Endpoints/assistants/title.js
+++ b/api/server/services/Endpoints/assistants/title.js
@@ -1,32 +1,89 @@
-const { isEnabled } = require('@librechat/api');
+const { isEnabled, sanitizeTitle } = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const { saveConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
+const initializeClient = require('./initalize');
-const addTitle = async (req, { text, responseText, conversationId, client }) => {
+/**
+ * Generates a conversation title using OpenAI SDK
+ * @param {Object} params
+ * @param {OpenAI} params.openai - The OpenAI SDK client instance
+ * @param {string} params.text - User's message text
+ * @param {string} params.responseText - Assistant's response text
+ * @returns {Promise}
+ */
+const generateTitle = async ({ openai, text, responseText }) => {
+ const titlePrompt = `Please generate a concise title (max 40 characters) for a conversation that starts with:
+User: ${text}
+Assistant: ${responseText}
+
+Title:`;
+
+ const completion = await openai.chat.completions.create({
+ model: 'gpt-3.5-turbo',
+ messages: [
+ {
+ role: 'user',
+ content: titlePrompt,
+ },
+ ],
+ temperature: 0.7,
+ max_tokens: 20,
+ });
+
+ const title = completion.choices[0]?.message?.content?.trim() || 'New conversation';
+ return sanitizeTitle(title);
+};
+
+/**
+ * Adds a title to a conversation asynchronously
+ * @param {ServerRequest} req
+ * @param {Object} params
+ * @param {string} params.text - User's message text
+ * @param {string} params.responseText - Assistant's response text
+ * @param {string} params.conversationId - Conversation ID
+ */
+const addTitle = async (req, { text, responseText, conversationId }) => {
const { TITLE_CONVO = 'true' } = process.env ?? {};
if (!isEnabled(TITLE_CONVO)) {
return;
}
- if (client.options.titleConvo === false) {
+ // Skip title generation for temporary conversations
+ if (req?.body?.isTemporary) {
return;
}
const titleCache = getLogStores(CacheKeys.GEN_TITLE);
const key = `${req.user.id}-${conversationId}`;
- const title = await client.titleConvo({ text, conversationId, responseText });
- await titleCache.set(key, title, 120000);
+ try {
+ const { openai } = await initializeClient({ req });
+ const title = await generateTitle({ openai, text, responseText });
+ await titleCache.set(key, title, 120000);
- await saveConvo(
- req,
- {
- conversationId,
- title,
- },
- { context: 'api/server/services/Endpoints/assistants/addTitle.js' },
- );
+ await saveConvo(
+ req,
+ {
+ conversationId,
+ title,
+ },
+ { context: 'api/server/services/Endpoints/assistants/addTitle.js', noUpsert: true },
+ );
+ } catch (error) {
+ logger.error('[addTitle] Error generating title:', error);
+ const fallbackTitle = text.length > 40 ? text.substring(0, 37) + '...' : text;
+ await titleCache.set(key, fallbackTitle, 120000);
+ await saveConvo(
+ req,
+ {
+ conversationId,
+ title: fallbackTitle,
+ },
+ { context: 'api/server/services/Endpoints/assistants/addTitle.js', noUpsert: true },
+ );
+ }
};
module.exports = addTitle;
diff --git a/api/server/services/Endpoints/azureAssistants/build.js b/api/server/services/Endpoints/azureAssistants/build.js
index 54a32e4d3c..53b1dbeb68 100644
--- a/api/server/services/Endpoints/azureAssistants/build.js
+++ b/api/server/services/Endpoints/azureAssistants/build.js
@@ -3,7 +3,6 @@ const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
const { getAssistant } = require('~/models/Assistant');
const buildOptions = async (endpoint, parsedBody) => {
-
const { promptPrefix, assistant_id, iconURL, greeting, spec, artifacts, ...modelOptions } =
parsedBody;
const endpointOption = removeNullishValues({
diff --git a/api/server/services/Endpoints/azureAssistants/initialize.js b/api/server/services/Endpoints/azureAssistants/initialize.js
index a6fb3e85f7..e81f0bcd8a 100644
--- a/api/server/services/Endpoints/azureAssistants/initialize.js
+++ b/api/server/services/Endpoints/azureAssistants/initialize.js
@@ -1,13 +1,13 @@
const OpenAI = require('openai');
const { ProxyAgent } = require('undici');
-const { constructAzureURL, isUserProvided, resolveHeaders } = require('@librechat/api');
-const { ErrorTypes, EModelEndpoint, mapModelToAzureConfig } = require('librechat-data-provider');
const {
+ isUserProvided,
+ resolveHeaders,
+ constructAzureURL,
checkUserKeyExpiry,
- getUserKeyValues,
- getUserKeyExpiry,
-} = require('~/server/services/UserService');
-const OAIClient = require('~/app/clients/OpenAIClient');
+} = require('@librechat/api');
+const { ErrorTypes, EModelEndpoint, mapModelToAzureConfig } = require('librechat-data-provider');
+const { getUserKeyValues, getUserKeyExpiry } = require('~/models');
class Files {
constructor(client) {
@@ -128,7 +128,6 @@ const initializeClient = async ({ req, res, version, endpointOption, initAppClie
const groupName = modelGroupMap[modelName].group;
clientOptions.addParams = azureConfig.groupMap[groupName].addParams;
clientOptions.dropParams = azureConfig.groupMap[groupName].dropParams;
- clientOptions.forcePrompt = azureConfig.groupMap[groupName].forcePrompt;
clientOptions.reverseProxyUrl = baseURL ?? clientOptions.reverseProxyUrl;
clientOptions.headers = opts.defaultHeaders;
@@ -184,15 +183,6 @@ const initializeClient = async ({ req, res, version, endpointOption, initAppClie
openai.locals = { ...(openai.locals ?? {}), azureOptions };
}
- if (endpointOption && initAppClient) {
- const client = new OAIClient(apiKey, clientOptions);
- return {
- client,
- openai,
- openAIApiKey: apiKey,
- };
- }
-
return {
openai,
openAIApiKey: apiKey,
diff --git a/api/server/services/Endpoints/azureAssistants/initialize.spec.js b/api/server/services/Endpoints/azureAssistants/initialize.spec.js
deleted file mode 100644
index d74373ae1b..0000000000
--- a/api/server/services/Endpoints/azureAssistants/initialize.spec.js
+++ /dev/null
@@ -1,134 +0,0 @@
-// const OpenAI = require('openai');
-const { ProxyAgent } = require('undici');
-const { ErrorTypes, EModelEndpoint } = require('librechat-data-provider');
-const { getUserKey, getUserKeyExpiry, getUserKeyValues } = require('~/server/services/UserService');
-const initializeClient = require('./initialize');
-// const { OpenAIClient } = require('~/app');
-
-jest.mock('~/server/services/UserService', () => ({
- getUserKey: jest.fn(),
- getUserKeyExpiry: jest.fn(),
- getUserKeyValues: jest.fn(),
- checkUserKeyExpiry: jest.requireActual('~/server/services/UserService').checkUserKeyExpiry,
-}));
-
-// Config is now passed via req.config, not getAppConfig
-
-const today = new Date();
-const tenDaysFromToday = new Date(today.setDate(today.getDate() + 10));
-const isoString = tenDaysFromToday.toISOString();
-
-describe('initializeClient', () => {
- // Set up environment variables
- const originalEnvironment = process.env;
- const app = {
- locals: {},
- };
-
- beforeEach(() => {
- jest.resetModules(); // Clears the cache
- process.env = { ...originalEnvironment }; // Make a copy
- });
-
- afterAll(() => {
- process.env = originalEnvironment; // Restore original env vars
- });
-
- test('initializes OpenAI client with default API key and URL', async () => {
- process.env.AZURE_ASSISTANTS_API_KEY = 'default-api-key';
- process.env.AZURE_ASSISTANTS_BASE_URL = 'https://default.api.url';
-
- // Assuming 'isUserProvided' to return false for this test case
- jest.mock('~/server/utils', () => ({
- isUserProvided: jest.fn().mockReturnValueOnce(false),
- }));
-
- const req = {
- user: { id: 'user123' },
- app,
- config: { endpoints: { [EModelEndpoint.azureOpenAI]: {} } },
- };
- const res = {};
-
- const { openai, openAIApiKey } = await initializeClient({ req, res });
- expect(openai.apiKey).toBe('default-api-key');
- expect(openAIApiKey).toBe('default-api-key');
- expect(openai.baseURL).toBe('https://default.api.url');
- });
-
- test('initializes OpenAI client with user-provided API key and URL', async () => {
- process.env.AZURE_ASSISTANTS_API_KEY = 'user_provided';
- process.env.AZURE_ASSISTANTS_BASE_URL = 'user_provided';
-
- getUserKeyValues.mockResolvedValue({ apiKey: 'user-api-key', baseURL: 'https://user.api.url' });
- getUserKeyExpiry.mockResolvedValue(isoString);
-
- const req = {
- user: { id: 'user123' },
- app,
- config: { endpoints: { [EModelEndpoint.azureOpenAI]: {} } },
- };
- const res = {};
-
- const { openai, openAIApiKey } = await initializeClient({ req, res });
- expect(openAIApiKey).toBe('user-api-key');
- expect(openai.apiKey).toBe('user-api-key');
- expect(openai.baseURL).toBe('https://user.api.url');
- });
-
- test('throws error for invalid JSON in user-provided values', async () => {
- process.env.AZURE_ASSISTANTS_API_KEY = 'user_provided';
- getUserKey.mockResolvedValue('invalid-json');
- getUserKeyExpiry.mockResolvedValue(isoString);
- getUserKeyValues.mockImplementation(() => {
- let userValues = getUserKey();
- try {
- userValues = JSON.parse(userValues);
- } catch {
- throw new Error(
- JSON.stringify({
- type: ErrorTypes.INVALID_USER_KEY,
- }),
- );
- }
- return userValues;
- });
-
- const req = {
- user: { id: 'user123' },
- config: { endpoints: { [EModelEndpoint.azureOpenAI]: {} } },
- };
- const res = {};
-
- await expect(initializeClient({ req, res })).rejects.toThrow(/invalid_user_key/);
- });
-
- test('throws error if API key is not provided', async () => {
- delete process.env.AZURE_ASSISTANTS_API_KEY; // Simulate missing API key
-
- const req = {
- user: { id: 'user123' },
- app,
- config: { endpoints: { [EModelEndpoint.azureOpenAI]: {} } },
- };
- const res = {};
-
- await expect(initializeClient({ req, res })).rejects.toThrow(/Assistants API key not/);
- });
-
- test('initializes OpenAI client with proxy configuration', async () => {
- process.env.AZURE_ASSISTANTS_API_KEY = 'test-key';
- process.env.PROXY = 'http://proxy.server';
-
- const req = {
- user: { id: 'user123' },
- app,
- config: { endpoints: { [EModelEndpoint.azureOpenAI]: {} } },
- };
- const res = {};
-
- const { openai } = await initializeClient({ req, res });
- expect(openai.fetchOptions).toBeDefined();
- expect(openai.fetchOptions.dispatcher).toBeInstanceOf(ProxyAgent);
- });
-});
diff --git a/api/server/services/Endpoints/bedrock/build.js b/api/server/services/Endpoints/bedrock/build.js
deleted file mode 100644
index b9f281bd99..0000000000
--- a/api/server/services/Endpoints/bedrock/build.js
+++ /dev/null
@@ -1,39 +0,0 @@
-const { removeNullishValues } = require('librechat-data-provider');
-const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
-
-const buildOptions = (endpoint, parsedBody) => {
- const {
- modelLabel: name,
- promptPrefix,
- maxContextTokens,
- fileTokenLimit,
- resendFiles = true,
- imageDetail,
- iconURL,
- greeting,
- spec,
- artifacts,
- ...model_parameters
- } = parsedBody;
- const endpointOption = removeNullishValues({
- endpoint,
- name,
- resendFiles,
- imageDetail,
- iconURL,
- greeting,
- spec,
- promptPrefix,
- maxContextTokens,
- fileTokenLimit,
- model_parameters,
- });
-
- if (typeof artifacts === 'string') {
- endpointOption.artifactsPrompt = generateArtifactsPrompt({ endpoint, artifacts });
- }
-
- return endpointOption;
-};
-
-module.exports = { buildOptions };
diff --git a/api/server/services/Endpoints/bedrock/index.js b/api/server/services/Endpoints/bedrock/index.js
deleted file mode 100644
index 8989f7df8c..0000000000
--- a/api/server/services/Endpoints/bedrock/index.js
+++ /dev/null
@@ -1,7 +0,0 @@
-const build = require('./build');
-const initialize = require('./initialize');
-
-module.exports = {
- ...build,
- ...initialize,
-};
diff --git a/api/server/services/Endpoints/bedrock/initialize.js b/api/server/services/Endpoints/bedrock/initialize.js
deleted file mode 100644
index bbee7caf39..0000000000
--- a/api/server/services/Endpoints/bedrock/initialize.js
+++ /dev/null
@@ -1,79 +0,0 @@
-const { getModelMaxTokens } = require('@librechat/api');
-const { createContentAggregator } = require('@librechat/agents');
-const {
- EModelEndpoint,
- providerEndpointMap,
- getResponseSender,
-} = require('librechat-data-provider');
-const { getDefaultHandlers } = require('~/server/controllers/agents/callbacks');
-const getOptions = require('~/server/services/Endpoints/bedrock/options');
-const AgentClient = require('~/server/controllers/agents/client');
-
-const initializeClient = async ({ req, res, endpointOption }) => {
- if (!endpointOption) {
- throw new Error('Endpoint option not provided');
- }
-
- /** @type {Array} */
- const collectedUsage = [];
- const { contentParts, aggregateContent } = createContentAggregator();
- const eventHandlers = getDefaultHandlers({ res, aggregateContent, collectedUsage });
-
- /** @type {Agent} */
- const agent = {
- id: EModelEndpoint.bedrock,
- name: endpointOption.name,
- provider: EModelEndpoint.bedrock,
- endpoint: EModelEndpoint.bedrock,
- instructions: endpointOption.promptPrefix,
- model: endpointOption.model_parameters.model,
- model_parameters: endpointOption.model_parameters,
- };
-
- if (typeof endpointOption.artifactsPrompt === 'string' && endpointOption.artifactsPrompt) {
- agent.instructions = `${agent.instructions ?? ''}\n${endpointOption.artifactsPrompt}`.trim();
- }
-
- // TODO: pass-in override settings that are specific to current run
- const options = await getOptions({
- req,
- res,
- endpointOption,
- });
-
- agent.model_parameters = Object.assign(agent.model_parameters, options.llmConfig);
- if (options.configOptions) {
- agent.model_parameters.configuration = options.configOptions;
- }
-
- const sender =
- agent.name ??
- getResponseSender({
- ...endpointOption,
- model: endpointOption.model_parameters.model,
- });
-
- const client = new AgentClient({
- req,
- res,
- agent,
- sender,
- // tools,
- contentParts,
- eventHandlers,
- collectedUsage,
- spec: endpointOption.spec,
- iconURL: endpointOption.iconURL,
- endpoint: EModelEndpoint.bedrock,
- resendFiles: endpointOption.resendFiles,
- maxContextTokens:
- endpointOption.maxContextTokens ??
- agent.max_context_tokens ??
- getModelMaxTokens(agent.model_parameters.model, providerEndpointMap[agent.provider]) ??
- 4000,
- attachments: endpointOption.attachments,
- });
- return { client };
-};
-
-module.exports = { initializeClient };
diff --git a/api/server/services/Endpoints/bedrock/options.js b/api/server/services/Endpoints/bedrock/options.js
deleted file mode 100644
index 0d02d09b07..0000000000
--- a/api/server/services/Endpoints/bedrock/options.js
+++ /dev/null
@@ -1,98 +0,0 @@
-const { HttpsProxyAgent } = require('https-proxy-agent');
-const {
- AuthType,
- EModelEndpoint,
- bedrockInputParser,
- bedrockOutputParser,
- removeNullishValues,
-} = require('librechat-data-provider');
-const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService');
-
-const getOptions = async ({ req, overrideModel, endpointOption }) => {
- const {
- BEDROCK_AWS_SECRET_ACCESS_KEY,
- BEDROCK_AWS_ACCESS_KEY_ID,
- BEDROCK_AWS_SESSION_TOKEN,
- BEDROCK_REVERSE_PROXY,
- BEDROCK_AWS_DEFAULT_REGION,
- PROXY,
- } = process.env;
- const expiresAt = req.body.key;
- const isUserProvided = BEDROCK_AWS_SECRET_ACCESS_KEY === AuthType.USER_PROVIDED;
-
- let credentials = isUserProvided
- ? await getUserKey({ userId: req.user.id, name: EModelEndpoint.bedrock })
- : {
- accessKeyId: BEDROCK_AWS_ACCESS_KEY_ID,
- secretAccessKey: BEDROCK_AWS_SECRET_ACCESS_KEY,
- ...(BEDROCK_AWS_SESSION_TOKEN && { sessionToken: BEDROCK_AWS_SESSION_TOKEN }),
- };
-
- if (!credentials) {
- throw new Error('Bedrock credentials not provided. Please provide them again.');
- }
-
- if (
- !isUserProvided &&
- (credentials.accessKeyId === undefined || credentials.accessKeyId === '') &&
- (credentials.secretAccessKey === undefined || credentials.secretAccessKey === '')
- ) {
- credentials = undefined;
- }
-
- if (expiresAt && isUserProvided) {
- checkUserKeyExpiry(expiresAt, EModelEndpoint.bedrock);
- }
-
- /*
- Callback for stream rate no longer awaits and may end the stream prematurely
- /** @type {number}
- let streamRate = Constants.DEFAULT_STREAM_RATE;
-
- /** @type {undefined | TBaseEndpoint}
- const bedrockConfig = appConfig.endpoints?.[EModelEndpoint.bedrock];
-
- if (bedrockConfig && bedrockConfig.streamRate) {
- streamRate = bedrockConfig.streamRate;
- }
-
- const allConfig = appConfig.endpoints?.all;
- if (allConfig && allConfig.streamRate) {
- streamRate = allConfig.streamRate;
- }
- */
-
- /** @type {BedrockClientOptions} */
- const requestOptions = {
- model: overrideModel ?? endpointOption?.model,
- region: BEDROCK_AWS_DEFAULT_REGION,
- };
-
- const configOptions = {};
- if (PROXY) {
- /** NOTE: NOT SUPPORTED BY BEDROCK */
- configOptions.httpAgent = new HttpsProxyAgent(PROXY);
- }
-
- const llmConfig = bedrockOutputParser(
- bedrockInputParser.parse(
- removeNullishValues(Object.assign(requestOptions, endpointOption?.model_parameters ?? {})),
- ),
- );
-
- if (credentials) {
- llmConfig.credentials = credentials;
- }
-
- if (BEDROCK_REVERSE_PROXY) {
- llmConfig.endpointHost = BEDROCK_REVERSE_PROXY;
- }
-
- return {
- /** @type {BedrockClientOptions} */
- llmConfig,
- configOptions,
- };
-};
-
-module.exports = getOptions;
diff --git a/api/server/services/Endpoints/custom/build.js b/api/server/services/Endpoints/custom/build.js
deleted file mode 100644
index b1839ee035..0000000000
--- a/api/server/services/Endpoints/custom/build.js
+++ /dev/null
@@ -1,42 +0,0 @@
-const { removeNullishValues } = require('librechat-data-provider');
-const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
-
-const buildOptions = (endpoint, parsedBody, endpointType) => {
- const {
- modelLabel,
- chatGptLabel,
- promptPrefix,
- maxContextTokens,
- fileTokenLimit,
- resendFiles = true,
- imageDetail,
- iconURL,
- greeting,
- spec,
- artifacts,
- ...modelOptions
- } = parsedBody;
- const endpointOption = removeNullishValues({
- endpoint,
- endpointType,
- modelLabel,
- chatGptLabel,
- promptPrefix,
- resendFiles,
- imageDetail,
- iconURL,
- greeting,
- spec,
- maxContextTokens,
- fileTokenLimit,
- modelOptions,
- });
-
- if (typeof artifacts === 'string') {
- endpointOption.artifactsPrompt = generateArtifactsPrompt({ endpoint, artifacts });
- }
-
- return endpointOption;
-};
-
-module.exports = buildOptions;
diff --git a/api/server/services/Endpoints/custom/index.js b/api/server/services/Endpoints/custom/index.js
deleted file mode 100644
index 5a70d78749..0000000000
--- a/api/server/services/Endpoints/custom/index.js
+++ /dev/null
@@ -1,7 +0,0 @@
-const initializeClient = require('./initialize');
-const buildOptions = require('./build');
-
-module.exports = {
- initializeClient,
- buildOptions,
-};
diff --git a/api/server/services/Endpoints/custom/initialize.js b/api/server/services/Endpoints/custom/initialize.js
deleted file mode 100644
index 5aa8b08a92..0000000000
--- a/api/server/services/Endpoints/custom/initialize.js
+++ /dev/null
@@ -1,157 +0,0 @@
-const { isUserProvided, getOpenAIConfig, getCustomEndpointConfig } = require('@librechat/api');
-const {
- CacheKeys,
- ErrorTypes,
- envVarRegex,
- FetchTokenConfig,
- extractEnvVariable,
-} = require('librechat-data-provider');
-const { getUserKeyValues, checkUserKeyExpiry } = require('~/server/services/UserService');
-const { fetchModels } = require('~/server/services/ModelService');
-const OpenAIClient = require('~/app/clients/OpenAIClient');
-const getLogStores = require('~/cache/getLogStores');
-
-const { PROXY } = process.env;
-
-const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrideEndpoint }) => {
- const appConfig = req.config;
- const { key: expiresAt } = req.body;
- const endpoint = overrideEndpoint ?? req.body.endpoint;
-
- const endpointConfig = getCustomEndpointConfig({
- endpoint,
- appConfig,
- });
- if (!endpointConfig) {
- throw new Error(`Config not found for the ${endpoint} custom endpoint.`);
- }
-
- const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey);
- const CUSTOM_BASE_URL = extractEnvVariable(endpointConfig.baseURL);
-
- if (CUSTOM_API_KEY.match(envVarRegex)) {
- throw new Error(`Missing API Key for ${endpoint}.`);
- }
-
- if (CUSTOM_BASE_URL.match(envVarRegex)) {
- throw new Error(`Missing Base URL for ${endpoint}.`);
- }
-
- const userProvidesKey = isUserProvided(CUSTOM_API_KEY);
- const userProvidesURL = isUserProvided(CUSTOM_BASE_URL);
-
- let userValues = null;
- if (expiresAt && (userProvidesKey || userProvidesURL)) {
- checkUserKeyExpiry(expiresAt, endpoint);
- userValues = await getUserKeyValues({ userId: req.user.id, name: endpoint });
- }
-
- let apiKey = userProvidesKey ? userValues?.apiKey : CUSTOM_API_KEY;
- let baseURL = userProvidesURL ? userValues?.baseURL : CUSTOM_BASE_URL;
-
- if (userProvidesKey & !apiKey) {
- throw new Error(
- JSON.stringify({
- type: ErrorTypes.NO_USER_KEY,
- }),
- );
- }
-
- if (userProvidesURL && !baseURL) {
- throw new Error(
- JSON.stringify({
- type: ErrorTypes.NO_BASE_URL,
- }),
- );
- }
-
- if (!apiKey) {
- throw new Error(`${endpoint} API key not provided.`);
- }
-
- if (!baseURL) {
- throw new Error(`${endpoint} Base URL not provided.`);
- }
-
- const cache = getLogStores(CacheKeys.TOKEN_CONFIG);
- const tokenKey =
- !endpointConfig.tokenConfig && (userProvidesKey || userProvidesURL)
- ? `${endpoint}:${req.user.id}`
- : endpoint;
-
- let endpointTokenConfig =
- !endpointConfig.tokenConfig &&
- FetchTokenConfig[endpoint.toLowerCase()] &&
- (await cache.get(tokenKey));
-
- if (
- FetchTokenConfig[endpoint.toLowerCase()] &&
- endpointConfig &&
- endpointConfig.models.fetch &&
- !endpointTokenConfig
- ) {
- await fetchModels({ apiKey, baseURL, name: endpoint, user: req.user.id, tokenKey });
- endpointTokenConfig = await cache.get(tokenKey);
- }
-
- const customOptions = {
- headers: endpointConfig.headers,
- addParams: endpointConfig.addParams,
- dropParams: endpointConfig.dropParams,
- customParams: endpointConfig.customParams,
- titleConvo: endpointConfig.titleConvo,
- titleModel: endpointConfig.titleModel,
- forcePrompt: endpointConfig.forcePrompt,
- summaryModel: endpointConfig.summaryModel,
- modelDisplayLabel: endpointConfig.modelDisplayLabel,
- titleMethod: endpointConfig.titleMethod ?? 'completion',
- contextStrategy: endpointConfig.summarize ? 'summarize' : null,
- directEndpoint: endpointConfig.directEndpoint,
- titleMessageRole: endpointConfig.titleMessageRole,
- streamRate: endpointConfig.streamRate,
- endpointTokenConfig,
- };
-
- const allConfig = appConfig.endpoints?.all;
- if (allConfig) {
- customOptions.streamRate = allConfig.streamRate;
- }
-
- let clientOptions = {
- reverseProxyUrl: baseURL ?? null,
- proxy: PROXY ?? null,
- req,
- res,
- ...customOptions,
- ...endpointOption,
- };
-
- if (optionsOnly) {
- const modelOptions = endpointOption?.model_parameters ?? {};
- clientOptions = Object.assign(
- {
- modelOptions,
- },
- clientOptions,
- );
- clientOptions.modelOptions.user = req.user.id;
- const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
- if (options != null) {
- options.useLegacyContent = true;
- options.endpointTokenConfig = endpointTokenConfig;
- }
- if (!clientOptions.streamRate) {
- return options;
- }
- options.llmConfig._lc_stream_delay = clientOptions.streamRate;
- return options;
- }
-
- const client = new OpenAIClient(apiKey, clientOptions);
- return {
- client,
- openAIApiKey: apiKey,
- };
-};
-
-module.exports = initializeClient;
diff --git a/api/server/services/Endpoints/custom/initialize.spec.js b/api/server/services/Endpoints/custom/initialize.spec.js
deleted file mode 100644
index d12906df9a..0000000000
--- a/api/server/services/Endpoints/custom/initialize.spec.js
+++ /dev/null
@@ -1,106 +0,0 @@
-const initializeClient = require('./initialize');
-
-jest.mock('@librechat/api', () => ({
- ...jest.requireActual('@librechat/api'),
- resolveHeaders: jest.fn(),
- getOpenAIConfig: jest.fn(),
- getCustomEndpointConfig: jest.fn().mockReturnValue({
- apiKey: 'test-key',
- baseURL: 'https://test.com',
- headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
- models: { default: ['test-model'] },
- }),
-}));
-
-jest.mock('~/server/services/UserService', () => ({
- getUserKeyValues: jest.fn(),
- checkUserKeyExpiry: jest.fn(),
-}));
-
-// Config is now passed via req.config, not getAppConfig
-
-jest.mock('~/server/services/ModelService', () => ({
- fetchModels: jest.fn(),
-}));
-
-jest.mock('~/app/clients/OpenAIClient', () => {
- return jest.fn().mockImplementation(() => ({
- options: {},
- }));
-});
-
-jest.mock('~/cache/getLogStores', () =>
- jest.fn().mockReturnValue({
- get: jest.fn(),
- }),
-);
-
-describe('custom/initializeClient', () => {
- const mockRequest = {
- body: { endpoint: 'test-endpoint' },
- user: { id: 'user-123', email: 'test@example.com', role: 'user' },
- app: { locals: {} },
- config: {
- endpoints: {
- all: {
- streamRate: 25,
- },
- },
- },
- };
- const mockResponse = {};
-
- beforeEach(() => {
- jest.clearAllMocks();
- const { getCustomEndpointConfig, resolveHeaders, getOpenAIConfig } = require('@librechat/api');
- getCustomEndpointConfig.mockReturnValue({
- apiKey: 'test-key',
- baseURL: 'https://test.com',
- headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
- models: { default: ['test-model'] },
- });
- resolveHeaders.mockReturnValue({ 'x-user': 'user-123', 'x-email': 'test@example.com' });
- getOpenAIConfig.mockReturnValue({
- useLegacyContent: true,
- endpointTokenConfig: null,
- llmConfig: {
- callbacks: [],
- },
- });
- });
-
- it('stores original template headers for deferred resolution', async () => {
- /**
- * Note: Request-based Header Resolution is deferred until right before LLM request is made
- * in the OpenAIClient or AgentClient, not during initialization.
- * This test verifies that the initialize function completes successfully with optionsOnly flag,
- * and that headers are passed through to be resolved later during the actual LLM request.
- */
- const result = await initializeClient({
- req: mockRequest,
- res: mockResponse,
- optionsOnly: true,
- });
- // Verify that options are returned for later use
- expect(result).toBeDefined();
- expect(result).toHaveProperty('useLegacyContent', true);
- });
-
- it('throws if endpoint config is missing', async () => {
- const { getCustomEndpointConfig } = require('@librechat/api');
- getCustomEndpointConfig.mockReturnValueOnce(null);
- await expect(
- initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true }),
- ).rejects.toThrow('Config not found for the test-endpoint custom endpoint.');
- });
-
- it('throws if user is missing', async () => {
- await expect(
- initializeClient({
- req: { ...mockRequest, user: undefined },
- res: mockResponse,
- optionsOnly: true,
- }),
- ).rejects.toThrow("Cannot read properties of undefined (reading 'id')");
- });
-});
diff --git a/api/server/services/Endpoints/google/build.js b/api/server/services/Endpoints/google/build.js
deleted file mode 100644
index 3ac6b167c4..0000000000
--- a/api/server/services/Endpoints/google/build.js
+++ /dev/null
@@ -1,39 +0,0 @@
-const { removeNullishValues } = require('librechat-data-provider');
-const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
-
-const buildOptions = (endpoint, parsedBody) => {
- const {
- examples,
- modelLabel,
- resendFiles = true,
- promptPrefix,
- iconURL,
- greeting,
- spec,
- artifacts,
- maxContextTokens,
- fileTokenLimit,
- ...modelOptions
- } = parsedBody;
- const endpointOption = removeNullishValues({
- examples,
- endpoint,
- modelLabel,
- resendFiles,
- promptPrefix,
- iconURL,
- greeting,
- spec,
- maxContextTokens,
- fileTokenLimit,
- modelOptions,
- });
-
- if (typeof artifacts === 'string') {
- endpointOption.artifactsPrompt = generateArtifactsPrompt({ endpoint, artifacts });
- }
-
- return endpointOption;
-};
-
-module.exports = buildOptions;
diff --git a/api/server/services/Endpoints/google/index.js b/api/server/services/Endpoints/google/index.js
deleted file mode 100644
index c4e7533c5d..0000000000
--- a/api/server/services/Endpoints/google/index.js
+++ /dev/null
@@ -1,9 +0,0 @@
-const addTitle = require('./title');
-const buildOptions = require('./build');
-const initializeClient = require('./initialize');
-
-module.exports = {
- addTitle,
- buildOptions,
- initializeClient,
-};
diff --git a/api/server/services/Endpoints/google/initialize.js b/api/server/services/Endpoints/google/initialize.js
deleted file mode 100644
index 9a685d679a..0000000000
--- a/api/server/services/Endpoints/google/initialize.js
+++ /dev/null
@@ -1,95 +0,0 @@
-const path = require('path');
-const { EModelEndpoint, AuthKeys } = require('librechat-data-provider');
-const { getGoogleConfig, isEnabled, loadServiceKey } = require('@librechat/api');
-const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService');
-const { GoogleClient } = require('~/app');
-
-const initializeClient = async ({ req, res, endpointOption, overrideModel, optionsOnly }) => {
- const { GOOGLE_KEY, GOOGLE_REVERSE_PROXY, GOOGLE_AUTH_HEADER, PROXY } = process.env;
- const isUserProvided = GOOGLE_KEY === 'user_provided';
- const { key: expiresAt } = req.body;
-
- let userKey = null;
- if (expiresAt && isUserProvided) {
- checkUserKeyExpiry(expiresAt, EModelEndpoint.google);
- userKey = await getUserKey({ userId: req.user.id, name: EModelEndpoint.google });
- }
-
- let serviceKey = {};
-
- /** Check if GOOGLE_KEY is provided at all (including 'user_provided') */
- const isGoogleKeyProvided =
- (GOOGLE_KEY && GOOGLE_KEY.trim() !== '') || (isUserProvided && userKey != null);
-
- if (!isGoogleKeyProvided) {
- /** Only attempt to load service key if GOOGLE_KEY is not provided */
- try {
- const serviceKeyPath =
- process.env.GOOGLE_SERVICE_KEY_FILE ||
- path.join(__dirname, '../../../..', 'data', 'auth.json');
- serviceKey = await loadServiceKey(serviceKeyPath);
- if (!serviceKey) {
- serviceKey = {};
- }
- } catch (_e) {
- // Service key loading failed, but that's okay if not required
- serviceKey = {};
- }
- }
-
- const credentials = isUserProvided
- ? userKey
- : {
- [AuthKeys.GOOGLE_SERVICE_KEY]: serviceKey,
- [AuthKeys.GOOGLE_API_KEY]: GOOGLE_KEY,
- };
-
- let clientOptions = {};
-
- const appConfig = req.config;
- /** @type {undefined | TBaseEndpoint} */
- const allConfig = appConfig.endpoints?.all;
- /** @type {undefined | TBaseEndpoint} */
- const googleConfig = appConfig.endpoints?.[EModelEndpoint.google];
-
- if (googleConfig) {
- clientOptions.streamRate = googleConfig.streamRate;
- clientOptions.titleModel = googleConfig.titleModel;
- }
-
- if (allConfig) {
- clientOptions.streamRate = allConfig.streamRate;
- }
-
- clientOptions = {
- req,
- res,
- reverseProxyUrl: GOOGLE_REVERSE_PROXY ?? null,
- authHeader: isEnabled(GOOGLE_AUTH_HEADER) ?? null,
- proxy: PROXY ?? null,
- ...clientOptions,
- ...endpointOption,
- };
-
- if (optionsOnly) {
- clientOptions = Object.assign(
- {
- modelOptions: endpointOption?.model_parameters ?? {},
- },
- clientOptions,
- );
- if (overrideModel) {
- clientOptions.modelOptions.model = overrideModel;
- }
- return getGoogleConfig(credentials, clientOptions);
- }
-
- const client = new GoogleClient(credentials, clientOptions);
-
- return {
- client,
- credentials,
- };
-};
-
-module.exports = initializeClient;
diff --git a/api/server/services/Endpoints/google/initialize.spec.js b/api/server/services/Endpoints/google/initialize.spec.js
deleted file mode 100644
index aa8a61e9c2..0000000000
--- a/api/server/services/Endpoints/google/initialize.spec.js
+++ /dev/null
@@ -1,101 +0,0 @@
-// file deepcode ignore HardcodedNonCryptoSecret: No hardcoded secrets
-const { getUserKey } = require('~/server/services/UserService');
-const initializeClient = require('./initialize');
-const { GoogleClient } = require('~/app');
-
-jest.mock('~/server/services/UserService', () => ({
- checkUserKeyExpiry: jest.requireActual('~/server/services/UserService').checkUserKeyExpiry,
- getUserKey: jest.fn().mockImplementation(() => ({})),
-}));
-
-// Config is now passed via req.config, not getAppConfig
-
-const app = { locals: {} };
-
-describe('google/initializeClient', () => {
- afterEach(() => {
- jest.clearAllMocks();
- });
-
- test('should initialize GoogleClient with user-provided credentials', async () => {
- process.env.GOOGLE_KEY = 'user_provided';
- process.env.GOOGLE_REVERSE_PROXY = 'http://reverse.proxy';
- process.env.PROXY = 'http://proxy';
-
- const expiresAt = new Date(Date.now() + 60000).toISOString();
-
- const req = {
- body: { key: expiresAt },
- user: { id: '123' },
- app,
- config: {
- endpoints: {
- all: {},
- google: {},
- },
- },
- };
- const res = {};
- const endpointOption = { modelOptions: { model: 'default-model' } };
-
- const { client, credentials } = await initializeClient({ req, res, endpointOption });
-
- expect(getUserKey).toHaveBeenCalledWith({ userId: '123', name: 'google' });
- expect(client).toBeInstanceOf(GoogleClient);
- expect(client.options.reverseProxyUrl).toBe('http://reverse.proxy');
- expect(client.options.proxy).toBe('http://proxy');
- expect(credentials).toEqual({});
- });
-
- test('should initialize GoogleClient with service key credentials', async () => {
- process.env.GOOGLE_KEY = 'service_key';
- process.env.GOOGLE_REVERSE_PROXY = 'http://reverse.proxy';
- process.env.PROXY = 'http://proxy';
-
- const req = {
- body: { key: null },
- user: { id: '123' },
- app,
- config: {
- endpoints: {
- all: {},
- google: {},
- },
- },
- };
- const res = {};
- const endpointOption = { modelOptions: { model: 'default-model' } };
-
- const { client, credentials } = await initializeClient({ req, res, endpointOption });
-
- expect(client).toBeInstanceOf(GoogleClient);
- expect(client.options.reverseProxyUrl).toBe('http://reverse.proxy');
- expect(client.options.proxy).toBe('http://proxy');
- expect(credentials).toEqual({
- GOOGLE_SERVICE_KEY: {},
- GOOGLE_API_KEY: 'service_key',
- });
- });
-
- test('should handle expired user-provided key', async () => {
- process.env.GOOGLE_KEY = 'user_provided';
-
- const expiresAt = new Date(Date.now() - 10000).toISOString(); // Expired
- const req = {
- body: { key: expiresAt },
- user: { id: '123' },
- app,
- config: {
- endpoints: {
- all: {},
- google: {},
- },
- },
- };
- const res = {};
- const endpointOption = { modelOptions: { model: 'default-model' } };
- await expect(initializeClient({ req, res, endpointOption })).rejects.toThrow(
- /expired_user_key/,
- );
- });
-});
diff --git a/api/server/services/Endpoints/google/title.js b/api/server/services/Endpoints/google/title.js
deleted file mode 100644
index 63ed8aae5f..0000000000
--- a/api/server/services/Endpoints/google/title.js
+++ /dev/null
@@ -1,60 +0,0 @@
-const { isEnabled } = require('@librechat/api');
-const { EModelEndpoint, CacheKeys, Constants, googleSettings } = require('librechat-data-provider');
-const getLogStores = require('~/cache/getLogStores');
-const initializeClient = require('./initialize');
-const { saveConvo } = require('~/models');
-
-const addTitle = async (req, { text, response, client }) => {
- const { TITLE_CONVO = 'true' } = process.env ?? {};
- if (!isEnabled(TITLE_CONVO)) {
- return;
- }
-
- if (client.options.titleConvo === false) {
- return;
- }
- const { GOOGLE_TITLE_MODEL } = process.env ?? {};
- const appConfig = req.config;
- const providerConfig = appConfig.endpoints?.[EModelEndpoint.google];
- let model =
- providerConfig?.titleModel ??
- GOOGLE_TITLE_MODEL ??
- client.options?.modelOptions.model ??
- googleSettings.model.default;
-
- if (GOOGLE_TITLE_MODEL === Constants.CURRENT_MODEL) {
- model = client.options?.modelOptions.model;
- }
-
- const titleEndpointOptions = {
- ...client.options,
- modelOptions: { ...client.options?.modelOptions, model: model },
- attachments: undefined, // After a response, this is set to an empty array which results in an error during setOptions
- };
-
- const { client: titleClient } = await initializeClient({
- req,
- res: response,
- endpointOption: titleEndpointOptions,
- });
-
- const titleCache = getLogStores(CacheKeys.GEN_TITLE);
- const key = `${req.user.id}-${response.conversationId}`;
-
- const title = await titleClient.titleConvo({
- text,
- responseText: response?.text ?? '',
- conversationId: response.conversationId,
- });
- await titleCache.set(key, title, 120000);
- await saveConvo(
- req,
- {
- conversationId: response.conversationId,
- title,
- },
- { context: 'api/server/services/Endpoints/google/addTitle.js' },
- );
-};
-
-module.exports = addTitle;
diff --git a/api/server/services/Endpoints/index.js b/api/server/services/Endpoints/index.js
index b18dfb7979..3cabfe1c58 100644
--- a/api/server/services/Endpoints/index.js
+++ b/api/server/services/Endpoints/index.js
@@ -12,15 +12,15 @@ const initGoogle = require('~/server/services/Endpoints/google/initialize');
* @returns {boolean} - True if the provider is a known custom provider, false otherwise
*/
function isKnownCustomProvider(provider) {
- return [Providers.XAI, Providers.OLLAMA, Providers.DEEPSEEK, Providers.OPENROUTER].includes(
+ return [Providers.XAI, Providers.DEEPSEEK, Providers.OPENROUTER, Providers.MOONSHOT].includes(
provider?.toLowerCase() || '',
);
}
const providerConfigMap = {
[Providers.XAI]: initCustom,
- [Providers.OLLAMA]: initCustom,
[Providers.DEEPSEEK]: initCustom,
+ [Providers.MOONSHOT]: initCustom,
[Providers.OPENROUTER]: initCustom,
[EModelEndpoint.openAI]: initOpenAI,
[EModelEndpoint.google]: initGoogle,
diff --git a/api/server/services/Endpoints/openAI/build.js b/api/server/services/Endpoints/openAI/build.js
deleted file mode 100644
index 611546a545..0000000000
--- a/api/server/services/Endpoints/openAI/build.js
+++ /dev/null
@@ -1,42 +0,0 @@
-const { removeNullishValues } = require('librechat-data-provider');
-const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
-
-const buildOptions = (endpoint, parsedBody) => {
- const {
- modelLabel,
- chatGptLabel,
- promptPrefix,
- maxContextTokens,
- fileTokenLimit,
- resendFiles = true,
- imageDetail,
- iconURL,
- greeting,
- spec,
- artifacts,
- ...modelOptions
- } = parsedBody;
-
- const endpointOption = removeNullishValues({
- endpoint,
- modelLabel,
- chatGptLabel,
- promptPrefix,
- resendFiles,
- imageDetail,
- iconURL,
- greeting,
- spec,
- maxContextTokens,
- fileTokenLimit,
- modelOptions,
- });
-
- if (typeof artifacts === 'string') {
- endpointOption.artifactsPrompt = generateArtifactsPrompt({ endpoint, artifacts });
- }
-
- return endpointOption;
-};
-
-module.exports = buildOptions;
diff --git a/api/server/services/Endpoints/openAI/index.js b/api/server/services/Endpoints/openAI/index.js
deleted file mode 100644
index c4e7533c5d..0000000000
--- a/api/server/services/Endpoints/openAI/index.js
+++ /dev/null
@@ -1,9 +0,0 @@
-const addTitle = require('./title');
-const buildOptions = require('./build');
-const initializeClient = require('./initialize');
-
-module.exports = {
- addTitle,
- buildOptions,
- initializeClient,
-};
diff --git a/api/server/services/Endpoints/openAI/initialize.js b/api/server/services/Endpoints/openAI/initialize.js
deleted file mode 100644
index cd691c6240..0000000000
--- a/api/server/services/Endpoints/openAI/initialize.js
+++ /dev/null
@@ -1,164 +0,0 @@
-const { ErrorTypes, EModelEndpoint, mapModelToAzureConfig } = require('librechat-data-provider');
-const {
- isEnabled,
- resolveHeaders,
- isUserProvided,
- getOpenAIConfig,
- getAzureCredentials,
-} = require('@librechat/api');
-const { getUserKeyValues, checkUserKeyExpiry } = require('~/server/services/UserService');
-const OpenAIClient = require('~/app/clients/OpenAIClient');
-
-const initializeClient = async ({
- req,
- res,
- endpointOption,
- optionsOnly,
- overrideEndpoint,
- overrideModel,
-}) => {
- const appConfig = req.config;
- const {
- PROXY,
- OPENAI_API_KEY,
- AZURE_API_KEY,
- OPENAI_REVERSE_PROXY,
- AZURE_OPENAI_BASEURL,
- OPENAI_SUMMARIZE,
- DEBUG_OPENAI,
- } = process.env;
- const { key: expiresAt } = req.body;
- const modelName = overrideModel ?? req.body.model;
- const endpoint = overrideEndpoint ?? req.body.endpoint;
- const contextStrategy = isEnabled(OPENAI_SUMMARIZE) ? 'summarize' : null;
-
- const credentials = {
- [EModelEndpoint.openAI]: OPENAI_API_KEY,
- [EModelEndpoint.azureOpenAI]: AZURE_API_KEY,
- };
-
- const baseURLOptions = {
- [EModelEndpoint.openAI]: OPENAI_REVERSE_PROXY,
- [EModelEndpoint.azureOpenAI]: AZURE_OPENAI_BASEURL,
- };
-
- const userProvidesKey = isUserProvided(credentials[endpoint]);
- const userProvidesURL = isUserProvided(baseURLOptions[endpoint]);
-
- let userValues = null;
- if (expiresAt && (userProvidesKey || userProvidesURL)) {
- checkUserKeyExpiry(expiresAt, endpoint);
- userValues = await getUserKeyValues({ userId: req.user.id, name: endpoint });
- }
-
- let apiKey = userProvidesKey ? userValues?.apiKey : credentials[endpoint];
- let baseURL = userProvidesURL ? userValues?.baseURL : baseURLOptions[endpoint];
-
- let clientOptions = {
- contextStrategy,
- proxy: PROXY ?? null,
- debug: isEnabled(DEBUG_OPENAI),
- reverseProxyUrl: baseURL ? baseURL : null,
- ...endpointOption,
- };
-
- const isAzureOpenAI = endpoint === EModelEndpoint.azureOpenAI;
- /** @type {false | TAzureConfig} */
- const azureConfig = isAzureOpenAI && appConfig.endpoints?.[EModelEndpoint.azureOpenAI];
- let serverless = false;
- if (isAzureOpenAI && azureConfig) {
- const { modelGroupMap, groupMap } = azureConfig;
- const {
- azureOptions,
- baseURL,
- headers = {},
- serverless: _serverless,
- } = mapModelToAzureConfig({
- modelName,
- modelGroupMap,
- groupMap,
- });
- serverless = _serverless;
-
- clientOptions.reverseProxyUrl = baseURL ?? clientOptions.reverseProxyUrl;
- clientOptions.headers = resolveHeaders({
- headers: { ...headers, ...(clientOptions.headers ?? {}) },
- user: req.user,
- });
-
- clientOptions.titleConvo = azureConfig.titleConvo;
- clientOptions.titleModel = azureConfig.titleModel;
-
- const azureRate = modelName.includes('gpt-4') ? 30 : 17;
- clientOptions.streamRate = azureConfig.streamRate ?? azureRate;
-
- clientOptions.titleMethod = azureConfig.titleMethod ?? 'completion';
-
- const groupName = modelGroupMap[modelName].group;
- clientOptions.addParams = azureConfig.groupMap[groupName].addParams;
- clientOptions.dropParams = azureConfig.groupMap[groupName].dropParams;
- clientOptions.forcePrompt = azureConfig.groupMap[groupName].forcePrompt;
-
- apiKey = azureOptions.azureOpenAIApiKey;
- clientOptions.azure = !serverless && azureOptions;
- if (serverless === true) {
- clientOptions.defaultQuery = azureOptions.azureOpenAIApiVersion
- ? { 'api-version': azureOptions.azureOpenAIApiVersion }
- : undefined;
- clientOptions.headers['api-key'] = apiKey;
- }
- } else if (isAzureOpenAI) {
- clientOptions.azure = userProvidesKey ? JSON.parse(userValues.apiKey) : getAzureCredentials();
- apiKey = clientOptions.azure.azureOpenAIApiKey;
- }
-
- /** @type {undefined | TBaseEndpoint} */
- const openAIConfig = appConfig.endpoints?.[EModelEndpoint.openAI];
-
- if (!isAzureOpenAI && openAIConfig) {
- clientOptions.streamRate = openAIConfig.streamRate;
- clientOptions.titleModel = openAIConfig.titleModel;
- }
-
- const allConfig = appConfig.endpoints?.all;
- if (allConfig) {
- clientOptions.streamRate = allConfig.streamRate;
- }
-
- if (userProvidesKey & !apiKey) {
- throw new Error(
- JSON.stringify({
- type: ErrorTypes.NO_USER_KEY,
- }),
- );
- }
-
- if (!apiKey) {
- throw new Error(`${endpoint} API Key not provided.`);
- }
-
- if (optionsOnly) {
- const modelOptions = endpointOption?.model_parameters ?? {};
- modelOptions.model = modelName;
- clientOptions = Object.assign({ modelOptions }, clientOptions);
- clientOptions.modelOptions.user = req.user.id;
- const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
- if (options != null && serverless === true) {
- options.useLegacyContent = true;
- }
- const streamRate = clientOptions.streamRate;
- if (!streamRate) {
- return options;
- }
- options.llmConfig._lc_stream_delay = streamRate;
- return options;
- }
-
- const client = new OpenAIClient(apiKey, Object.assign({ req, res }, clientOptions));
- return {
- client,
- openAIApiKey: apiKey,
- };
-};
-
-module.exports = initializeClient;
diff --git a/api/server/services/Endpoints/openAI/initialize.spec.js b/api/server/services/Endpoints/openAI/initialize.spec.js
deleted file mode 100644
index d51300aafe..0000000000
--- a/api/server/services/Endpoints/openAI/initialize.spec.js
+++ /dev/null
@@ -1,431 +0,0 @@
-jest.mock('~/cache/getLogStores', () => ({
- getLogStores: jest.fn().mockReturnValue({
- get: jest.fn().mockResolvedValue({
- openAI: { apiKey: 'test-key' },
- }),
- set: jest.fn(),
- delete: jest.fn(),
- }),
-}));
-
-const { EModelEndpoint, ErrorTypes, validateAzureGroups } = require('librechat-data-provider');
-const { getUserKey, getUserKeyValues } = require('~/server/services/UserService');
-const initializeClient = require('./initialize');
-const { OpenAIClient } = require('~/app');
-
-// Mock getUserKey since it's the only function we want to mock
-jest.mock('~/server/services/UserService', () => ({
- getUserKey: jest.fn(),
- getUserKeyValues: jest.fn(),
- checkUserKeyExpiry: jest.requireActual('~/server/services/UserService').checkUserKeyExpiry,
-}));
-
-const mockAppConfig = {
- endpoints: {
- openAI: {
- apiKey: 'test-key',
- },
- azureOpenAI: {
- apiKey: 'test-azure-key',
- modelNames: ['gpt-4-vision-preview', 'gpt-3.5-turbo', 'gpt-4'],
- modelGroupMap: {
- 'gpt-4-vision-preview': {
- group: 'librechat-westus',
- deploymentName: 'gpt-4-vision-preview',
- version: '2024-02-15-preview',
- },
- },
- groupMap: {
- 'librechat-westus': {
- apiKey: 'WESTUS_API_KEY',
- instanceName: 'librechat-westus',
- version: '2023-12-01-preview',
- models: {
- 'gpt-4-vision-preview': {
- deploymentName: 'gpt-4-vision-preview',
- version: '2024-02-15-preview',
- },
- },
- },
- },
- },
- },
-};
-
-describe('initializeClient', () => {
- // Set up environment variables
- const originalEnvironment = process.env;
- const app = {
- locals: {},
- };
-
- const validAzureConfigs = [
- {
- group: 'librechat-westus',
- apiKey: 'WESTUS_API_KEY',
- instanceName: 'librechat-westus',
- version: '2023-12-01-preview',
- models: {
- 'gpt-4-vision-preview': {
- deploymentName: 'gpt-4-vision-preview',
- version: '2024-02-15-preview',
- },
- 'gpt-3.5-turbo': {
- deploymentName: 'gpt-35-turbo',
- },
- 'gpt-3.5-turbo-1106': {
- deploymentName: 'gpt-35-turbo-1106',
- },
- 'gpt-4': {
- deploymentName: 'gpt-4',
- },
- 'gpt-4-1106-preview': {
- deploymentName: 'gpt-4-1106-preview',
- },
- },
- },
- {
- group: 'librechat-eastus',
- apiKey: 'EASTUS_API_KEY',
- instanceName: 'librechat-eastus',
- deploymentName: 'gpt-4-turbo',
- version: '2024-02-15-preview',
- models: {
- 'gpt-4-turbo': true,
- },
- baseURL: 'https://eastus.example.com',
- additionalHeaders: {
- 'x-api-key': 'x-api-key-value',
- },
- },
- {
- group: 'mistral-inference',
- apiKey: 'AZURE_MISTRAL_API_KEY',
- baseURL:
- 'https://Mistral-large-vnpet-serverless.region.inference.ai.azure.com/v1/chat/completions',
- serverless: true,
- models: {
- 'mistral-large': true,
- },
- },
- {
- group: 'llama-70b-chat',
- apiKey: 'AZURE_LLAMA2_70B_API_KEY',
- baseURL:
- 'https://Llama-2-70b-chat-qmvyb-serverless.region.inference.ai.azure.com/v1/chat/completions',
- serverless: true,
- models: {
- 'llama-70b-chat': true,
- },
- },
- ];
-
- const { modelNames } = validateAzureGroups(validAzureConfigs);
-
- beforeEach(() => {
- jest.resetModules(); // Clears the cache
- process.env = { ...originalEnvironment }; // Make a copy
- });
-
- afterAll(() => {
- process.env = originalEnvironment; // Restore original env vars
- });
-
- test('should initialize client with OpenAI API key and default options', async () => {
- process.env.OPENAI_API_KEY = 'test-openai-api-key';
- process.env.DEBUG_OPENAI = 'false';
- process.env.OPENAI_SUMMARIZE = 'false';
-
- const req = {
- body: { key: null, endpoint: EModelEndpoint.openAI },
- user: { id: '123' },
- app,
- config: mockAppConfig,
- };
- const res = {};
- const endpointOption = {};
-
- const result = await initializeClient({ req, res, endpointOption });
-
- expect(result.openAIApiKey).toBe('test-openai-api-key');
- expect(result.client).toBeInstanceOf(OpenAIClient);
- });
-
- test('should initialize client with Azure credentials when endpoint is azureOpenAI', async () => {
- process.env.AZURE_API_KEY = 'test-azure-api-key';
- (process.env.AZURE_OPENAI_API_INSTANCE_NAME = 'some-value'),
- (process.env.AZURE_OPENAI_API_DEPLOYMENT_NAME = 'some-value'),
- (process.env.AZURE_OPENAI_API_VERSION = 'some-value'),
- (process.env.AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME = 'some-value'),
- (process.env.AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME = 'some-value'),
- (process.env.OPENAI_API_KEY = 'test-openai-api-key');
- process.env.DEBUG_OPENAI = 'false';
- process.env.OPENAI_SUMMARIZE = 'false';
-
- const req = {
- body: {
- key: null,
- endpoint: 'azureOpenAI',
- model: 'gpt-4-vision-preview',
- },
- user: { id: '123' },
- app,
- config: mockAppConfig,
- };
- const res = {};
- const endpointOption = {};
-
- const client = await initializeClient({ req, res, endpointOption });
-
- expect(client.openAIApiKey).toBe('WESTUS_API_KEY');
- expect(client.client).toBeInstanceOf(OpenAIClient);
- });
-
- test('should use the debug option when DEBUG_OPENAI is enabled', async () => {
- process.env.OPENAI_API_KEY = 'test-openai-api-key';
- process.env.DEBUG_OPENAI = 'true';
-
- const req = {
- body: { key: null, endpoint: EModelEndpoint.openAI },
- user: { id: '123' },
- app,
- config: mockAppConfig,
- };
- const res = {};
- const endpointOption = {};
-
- const client = await initializeClient({ req, res, endpointOption });
-
- expect(client.client.options.debug).toBe(true);
- });
-
- test('should set contextStrategy to summarize when OPENAI_SUMMARIZE is enabled', async () => {
- process.env.OPENAI_API_KEY = 'test-openai-api-key';
- process.env.OPENAI_SUMMARIZE = 'true';
-
- const req = {
- body: { key: null, endpoint: EModelEndpoint.openAI },
- user: { id: '123' },
- app,
- config: mockAppConfig,
- };
- const res = {};
- const endpointOption = {};
-
- const client = await initializeClient({ req, res, endpointOption });
-
- expect(client.client.options.contextStrategy).toBe('summarize');
- });
-
- test('should set reverseProxyUrl and proxy when they are provided in the environment', async () => {
- process.env.OPENAI_API_KEY = 'test-openai-api-key';
- process.env.OPENAI_REVERSE_PROXY = 'http://reverse.proxy';
- process.env.PROXY = 'http://proxy';
-
- const req = {
- body: { key: null, endpoint: EModelEndpoint.openAI },
- user: { id: '123' },
- app,
- config: mockAppConfig,
- };
- const res = {};
- const endpointOption = {};
-
- const client = await initializeClient({ req, res, endpointOption });
-
- expect(client.client.options.reverseProxyUrl).toBe('http://reverse.proxy');
- expect(client.client.options.proxy).toBe('http://proxy');
- });
-
- test('should throw an error if the user-provided key has expired', async () => {
- process.env.OPENAI_API_KEY = 'user_provided';
- process.env.AZURE_API_KEY = 'user_provided';
- process.env.DEBUG_OPENAI = 'false';
- process.env.OPENAI_SUMMARIZE = 'false';
-
- const expiresAt = new Date(Date.now() - 10000).toISOString(); // Expired
- const req = {
- body: { key: expiresAt, endpoint: EModelEndpoint.openAI },
- user: { id: '123' },
- app,
- config: mockAppConfig,
- };
- const res = {};
- const endpointOption = {};
-
- await expect(initializeClient({ req, res, endpointOption })).rejects.toThrow(
- /expired_user_key/,
- );
- });
-
- test('should throw an error if no API keys are provided in the environment', async () => {
- // Clear the environment variables for API keys
- delete process.env.OPENAI_API_KEY;
- delete process.env.AZURE_API_KEY;
-
- const req = {
- body: { key: null, endpoint: EModelEndpoint.openAI },
- user: { id: '123' },
- app,
- config: mockAppConfig,
- };
- const res = {};
- const endpointOption = {};
-
- await expect(initializeClient({ req, res, endpointOption })).rejects.toThrow(
- `${EModelEndpoint.openAI} API Key not provided.`,
- );
- });
-
- it('should handle user-provided keys and check expiry', async () => {
- // Set up the req.body to simulate user-provided key scenario
- const req = {
- body: {
- key: new Date(Date.now() + 10000).toISOString(),
- endpoint: EModelEndpoint.openAI,
- },
- user: {
- id: '123',
- },
- app,
- config: mockAppConfig,
- };
-
- const res = {};
- const endpointOption = {};
-
- // Ensure the environment variable is set to 'user_provided' to match the isUserProvided condition
- process.env.OPENAI_API_KEY = 'user_provided';
-
- // Mock getUserKey to return the expected key
- getUserKeyValues.mockResolvedValue({ apiKey: 'test-user-provided-openai-api-key' });
-
- // Call the initializeClient function
- const result = await initializeClient({ req, res, endpointOption });
-
- // Assertions
- expect(result.openAIApiKey).toBe('test-user-provided-openai-api-key');
- });
-
- test('should throw an error if the user-provided key is invalid', async () => {
- const invalidKey = new Date(Date.now() - 100000).toISOString();
- const req = {
- body: { key: invalidKey, endpoint: EModelEndpoint.openAI },
- user: { id: '123' },
- app,
- config: mockAppConfig,
- };
- const res = {};
- const endpointOption = {};
-
- // Ensure the environment variable is set to 'user_provided' to match the isUserProvided condition
- process.env.OPENAI_API_KEY = 'user_provided';
-
- // Mock getUserKey to return an invalid key
- getUserKey.mockResolvedValue(invalidKey);
-
- await expect(initializeClient({ req, res, endpointOption })).rejects.toThrow(
- /expired_user_key/,
- );
- });
-
- test('should throw an error when user-provided values are not valid JSON', async () => {
- process.env.OPENAI_API_KEY = 'user_provided';
- const req = {
- body: { key: new Date(Date.now() + 10000).toISOString(), endpoint: EModelEndpoint.openAI },
- user: { id: '123' },
- app,
- config: mockAppConfig,
- };
- const res = {};
- const endpointOption = {};
-
- // Mock getUserKey to return a non-JSON string
- getUserKey.mockResolvedValue('not-a-json');
- getUserKeyValues.mockImplementation(() => {
- let userValues = getUserKey();
- try {
- userValues = JSON.parse(userValues);
- } catch {
- throw new Error(
- JSON.stringify({
- type: ErrorTypes.INVALID_USER_KEY,
- }),
- );
- }
- return userValues;
- });
-
- await expect(initializeClient({ req, res, endpointOption })).rejects.toThrow(
- /invalid_user_key/,
- );
- });
-
- test('should initialize client correctly for Azure OpenAI with valid configuration', async () => {
- // Set up Azure environment variables
- process.env.WESTUS_API_KEY = 'test-westus-key';
-
- const req = {
- body: {
- key: null,
- endpoint: EModelEndpoint.azureOpenAI,
- model: modelNames[0],
- },
- user: { id: '123' },
- config: mockAppConfig,
- };
- const res = {};
- const endpointOption = {};
-
- const client = await initializeClient({ req, res, endpointOption });
- expect(client.client.options.azure).toBeDefined();
- });
-
- test('should initialize client with default options when certain env vars are not set', async () => {
- delete process.env.DEBUG_OPENAI;
- delete process.env.OPENAI_SUMMARIZE;
- process.env.OPENAI_API_KEY = 'some-api-key';
-
- const req = {
- body: { key: null, endpoint: EModelEndpoint.openAI },
- user: { id: '123' },
- app,
- config: mockAppConfig,
- };
- const res = {};
- const endpointOption = {};
-
- const client = await initializeClient({ req, res, endpointOption });
-
- expect(client.client.options.debug).toBe(false);
- expect(client.client.options.contextStrategy).toBe(null);
- });
-
- test('should correctly use user-provided apiKey and baseURL when provided', async () => {
- process.env.OPENAI_API_KEY = 'user_provided';
- process.env.OPENAI_REVERSE_PROXY = 'user_provided';
- const req = {
- body: {
- key: new Date(Date.now() + 10000).toISOString(),
- endpoint: EModelEndpoint.openAI,
- },
- user: {
- id: '123',
- },
- app,
- config: mockAppConfig,
- };
- const res = {};
- const endpointOption = {};
-
- getUserKeyValues.mockResolvedValue({
- apiKey: 'test',
- baseURL: 'https://user-provided-url.com',
- });
-
- const result = await initializeClient({ req, res, endpointOption });
-
- expect(result.openAIApiKey).toBe('test');
- expect(result.client.options.reverseProxyUrl).toBe('https://user-provided-url.com');
- });
-});
diff --git a/api/server/services/Endpoints/openAI/title.js b/api/server/services/Endpoints/openAI/title.js
deleted file mode 100644
index f8624ef657..0000000000
--- a/api/server/services/Endpoints/openAI/title.js
+++ /dev/null
@@ -1,35 +0,0 @@
-const { isEnabled } = require('@librechat/api');
-const { CacheKeys } = require('librechat-data-provider');
-const getLogStores = require('~/cache/getLogStores');
-const { saveConvo } = require('~/models');
-
-const addTitle = async (req, { text, response, client }) => {
- const { TITLE_CONVO = 'true' } = process.env ?? {};
- if (!isEnabled(TITLE_CONVO)) {
- return;
- }
-
- if (client.options.titleConvo === false) {
- return;
- }
-
- const titleCache = getLogStores(CacheKeys.GEN_TITLE);
- const key = `${req.user.id}-${response.conversationId}`;
-
- const title = await client.titleConvo({
- text,
- responseText: response?.text ?? '',
- conversationId: response.conversationId,
- });
- await titleCache.set(key, title, 120000);
- await saveConvo(
- req,
- {
- conversationId: response.conversationId,
- title,
- },
- { context: 'api/server/services/Endpoints/openAI/addTitle.js' },
- );
-};
-
-module.exports = addTitle;
diff --git a/api/server/services/Files/Audio/STTService.js b/api/server/services/Files/Audio/STTService.js
index 16f806de4e..4ba62a7eeb 100644
--- a/api/server/services/Files/Audio/STTService.js
+++ b/api/server/services/Files/Audio/STTService.js
@@ -3,7 +3,8 @@ const fs = require('fs').promises;
const FormData = require('form-data');
const { Readable } = require('stream');
const { logger } = require('@librechat/data-schemas');
-const { genAzureEndpoint } = require('@librechat/api');
+const { HttpsProxyAgent } = require('https-proxy-agent');
+const { genAzureEndpoint, logAxiosError } = require('@librechat/api');
const { extractEnvVariable, STTProviders } = require('librechat-data-provider');
const { getAppConfig } = require('~/server/services/Config');
@@ -34,6 +35,34 @@ const MIME_TO_EXTENSION_MAP = {
'audio/x-flac': 'flac',
};
+/**
+ * Validates and extracts ISO-639-1 language code from a locale string.
+ * @param {string} language - The language/locale string (e.g., "en-US", "en", "zh-CN")
+ * @returns {string|null} The ISO-639-1 language code (e.g., "en") or null if invalid
+ */
+function getValidatedLanguageCode(language) {
+ try {
+ if (!language) {
+ return null;
+ }
+
+ const normalizedLanguage = language.toLowerCase();
+ const isValidLocaleCode = /^[a-z]{2}(-[a-z]{2})?$/.test(normalizedLanguage);
+
+ if (isValidLocaleCode) {
+ return normalizedLanguage.split('-')[0];
+ }
+
+ logger.warn(
+ `[STT] Invalid language format "${language}". Expected ISO-639-1 locale code like "en-US" or "en". Skipping language parameter.`,
+ );
+ return null;
+ } catch (error) {
+ logger.error(`[STT] Error validating language code "${language}":`, error);
+ return null;
+ }
+}
+
/**
* Gets the file extension from the MIME type.
* @param {string} mimeType - The MIME type.
@@ -172,10 +201,9 @@ class STTService {
model: sttSchema.model,
};
- if (language) {
- /** Converted locale code (e.g., "en-US") to ISO-639-1 format (e.g., "en") */
- const isoLanguage = language.split('-')[0];
- data.language = isoLanguage;
+ const validLanguage = getValidatedLanguageCode(language);
+ if (validLanguage) {
+ data.language = validLanguage;
}
const headers = {
@@ -220,10 +248,9 @@ class STTService {
contentType: audioFile.mimetype,
});
- if (language) {
- /** Converted locale code (e.g., "en-US") to ISO-639-1 format (e.g., "en") */
- const isoLanguage = language.split('-')[0];
- formData.append('language', isoLanguage);
+ const validLanguage = getValidatedLanguageCode(language);
+ if (validLanguage) {
+ formData.append('language', validLanguage);
}
const headers = {
@@ -266,8 +293,14 @@ class STTService {
language,
);
+ const options = { headers };
+
+ if (process.env.PROXY) {
+ options.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
+ }
+
try {
- const response = await axios.post(url, data, { headers });
+ const response = await axios.post(url, data, options);
if (response.status !== 200) {
throw new Error('Invalid response from the STT API');
@@ -279,7 +312,7 @@ class STTService {
return response.data.text.trim();
} catch (error) {
- logger.error(`STT request failed for provider ${provider}:`, error);
+ logAxiosError({ message: `STT request failed for provider ${provider}:`, error });
throw error;
}
}
@@ -309,7 +342,7 @@ class STTService {
const text = await this.sttRequest(provider, sttSchema, { audioBuffer, audioFile, language });
res.json({ text });
} catch (error) {
- logger.error('An error occurred while processing the audio:', error);
+ logAxiosError({ message: 'An error occurred while processing the audio:', error });
res.sendStatus(500);
} finally {
try {
diff --git a/api/server/services/Files/Audio/TTSService.js b/api/server/services/Files/Audio/TTSService.js
index 2f36c4b9c6..2c932968c6 100644
--- a/api/server/services/Files/Audio/TTSService.js
+++ b/api/server/services/Files/Audio/TTSService.js
@@ -1,6 +1,7 @@
const axios = require('axios');
const { logger } = require('@librechat/data-schemas');
-const { genAzureEndpoint } = require('@librechat/api');
+const { HttpsProxyAgent } = require('https-proxy-agent');
+const { genAzureEndpoint, logAxiosError } = require('@librechat/api');
const { extractEnvVariable, TTSProviders } = require('librechat-data-provider');
const { getRandomVoiceId, createChunkProcessor, splitTextIntoChunks } = require('./streamAudio');
const { getAppConfig } = require('~/server/services/Config');
@@ -266,10 +267,14 @@ class TTSService {
const options = { headers, responseType: stream ? 'stream' : 'arraybuffer' };
+ if (process.env.PROXY) {
+ options.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
+ }
+
try {
return await axios.post(url, data, options);
} catch (error) {
- logger.error(`TTS request failed for provider ${provider}:`, error);
+ logAxiosError({ message: `TTS request failed for provider ${provider}:`, error });
throw error;
}
}
@@ -325,7 +330,10 @@ class TTSService {
break;
}
} catch (innerError) {
- logger.error('Error processing manual update:', chunk, innerError);
+ logAxiosError({
+ message: `[TTS] Error processing manual update for chunk: ${chunk?.text?.substring(0, 50)}...`,
+ error: innerError,
+ });
if (!res.headersSent) {
return res.status(500).end();
}
@@ -337,7 +345,7 @@ class TTSService {
res.end();
}
} catch (error) {
- logger.error('Error creating the audio stream:', error);
+ logAxiosError({ message: '[TTS] Error creating the audio stream:', error });
if (!res.headersSent) {
return res.status(500).send('An error occurred');
}
@@ -407,7 +415,10 @@ class TTSService {
break;
}
} catch (innerError) {
- logger.error('Error processing audio stream update:', update, innerError);
+ logAxiosError({
+ message: `[TTS] Error processing audio stream update: ${update?.text?.substring(0, 50)}...`,
+ error: innerError,
+ });
if (!res.headersSent) {
return res.status(500).end();
}
@@ -424,7 +435,7 @@ class TTSService {
res.end();
}
} catch (error) {
- logger.error('Failed to fetch audio:', error);
+ logAxiosError({ message: '[TTS] Failed to fetch audio:', error });
if (!res.headersSent) {
res.status(500).end();
}
diff --git a/api/server/services/Files/Azure/crud.js b/api/server/services/Files/Azure/crud.js
index 25bd749276..8f681bd06c 100644
--- a/api/server/services/Files/Azure/crud.js
+++ b/api/server/services/Files/Azure/crud.js
@@ -4,7 +4,7 @@ const mime = require('mime');
const axios = require('axios');
const fetch = require('node-fetch');
const { logger } = require('@librechat/data-schemas');
-const { getAzureContainerClient } = require('@librechat/api');
+const { getAzureContainerClient, deleteRagFile } = require('@librechat/api');
const defaultBasePath = 'images';
const { AZURE_STORAGE_PUBLIC_ACCESS = 'true', AZURE_CONTAINER_NAME = 'files' } = process.env;
@@ -102,6 +102,8 @@ async function getAzureURL({ fileName, basePath = defaultBasePath, userId, conta
* @param {MongoFile} params.file - The file object.
*/
async function deleteFileFromAzure(req, file) {
+ await deleteRagFile({ userId: req.user.id, file });
+
try {
const containerClient = await getAzureContainerClient(AZURE_CONTAINER_NAME);
const blobPath = file.filepath.split(`${AZURE_CONTAINER_NAME}/`)[1];
diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js
index c38aad7087..3f0bfcfc87 100644
--- a/api/server/services/Files/Code/process.js
+++ b/api/server/services/Files/Code/process.js
@@ -6,27 +6,68 @@ const { getCodeBaseURL } = require('@librechat/agents');
const { logAxiosError, getBasePath } = require('@librechat/api');
const {
Tools,
+ megabyte,
+ fileConfig,
FileContext,
FileSources,
imageExtRegex,
+ inferMimeType,
EToolResources,
+ EModelEndpoint,
+ mergeFileConfig,
+ getEndpointFileConfig,
} = require('librechat-data-provider');
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
+const { createFile, getFiles, updateFile, claimCodeFile } = require('~/models');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { convertImage } = require('~/server/services/Files/images/convert');
-const { createFile, getFiles, updateFile } = require('~/models/File');
+const { determineFileType } = require('~/server/utils');
/**
- * Process OpenAI image files, convert to target format, save and return file metadata.
+ * Creates a fallback download URL response when file cannot be processed locally.
+ * Used when: file exceeds size limit, storage strategy unavailable, or download error occurs.
+ * @param {Object} params - The parameters.
+ * @param {string} params.name - The filename.
+ * @param {string} params.session_id - The code execution session ID.
+ * @param {string} params.id - The file ID from the code environment.
+ * @param {string} params.conversationId - The current conversation ID.
+ * @param {string} params.toolCallId - The tool call ID that generated the file.
+ * @param {string} params.messageId - The current message ID.
+ * @param {number} params.expiresAt - Expiration timestamp (24 hours from creation).
+ * @returns {Object} Fallback response with download URL.
+ */
+const createDownloadFallback = ({
+ id,
+ name,
+ messageId,
+ expiresAt,
+ session_id,
+ toolCallId,
+ conversationId,
+}) => {
+ const basePath = getBasePath();
+ return {
+ filename: name,
+ filepath: `${basePath}/api/files/code/download/${session_id}/${id}`,
+ expiresAt,
+ conversationId,
+ toolCallId,
+ messageId,
+ };
+};
+
+/**
+ * Process code execution output files - downloads and saves both images and non-image files.
+ * All files are saved to local storage with fileIdentifier metadata for code env re-upload.
* @param {ServerRequest} params.req - The Express request object.
- * @param {string} params.id - The file ID.
+ * @param {string} params.id - The file ID from the code environment.
* @param {string} params.name - The filename.
* @param {string} params.apiKey - The code execution API key.
* @param {string} params.toolCallId - The tool call ID that generated the file.
* @param {string} params.session_id - The code execution session ID.
* @param {string} params.conversationId - The current conversation ID.
* @param {string} params.messageId - The current message ID.
- * @returns {Promise} The file metadata or undefined if an error occurs.
+ * @returns {Promise} The file metadata or undefined if an error occurs.
*/
const processCodeOutput = async ({
req,
@@ -41,19 +82,15 @@ const processCodeOutput = async ({
const appConfig = req.config;
const currentDate = new Date();
const baseURL = getCodeBaseURL();
- const basePath = getBasePath();
- const fileExt = path.extname(name);
- if (!fileExt || !imageExtRegex.test(name)) {
- return {
- filename: name,
- filepath: `${basePath}/api/files/code/download/${session_id}/${id}`,
- /** Note: expires 24 hours after creation */
- expiresAt: currentDate.getTime() + 86400000,
- conversationId,
- toolCallId,
- messageId,
- };
- }
+ const fileExt = path.extname(name).toLowerCase();
+ const isImage = fileExt && imageExtRegex.test(name);
+
+ const mergedFileConfig = mergeFileConfig(appConfig.fileConfig);
+ const endpointFileConfig = getEndpointFileConfig({
+ fileConfig: mergedFileConfig,
+ endpoint: EModelEndpoint.agents,
+ });
+ const fileSizeLimit = endpointFileConfig.fileSizeLimit ?? mergedFileConfig.serverFileSizeLimit;
try {
const formattedDate = currentDate.toISOString();
@@ -70,29 +107,143 @@ const processCodeOutput = async ({
const buffer = Buffer.from(response.data, 'binary');
- const file_id = v4();
- const _file = await convertImage(req, buffer, 'high', `${file_id}${fileExt}`);
- const file = {
- ..._file,
- file_id,
- usage: 1,
+ // Enforce file size limit
+ if (buffer.length > fileSizeLimit) {
+ logger.warn(
+ `[processCodeOutput] File "${name}" (${(buffer.length / megabyte).toFixed(2)} MB) exceeds size limit of ${(fileSizeLimit / megabyte).toFixed(2)} MB, falling back to download URL`,
+ );
+ return createDownloadFallback({
+ id,
+ name,
+ messageId,
+ toolCallId,
+ session_id,
+ conversationId,
+ expiresAt: currentDate.getTime() + 86400000,
+ });
+ }
+
+ const fileIdentifier = `${session_id}/${id}`;
+
+ /**
+ * Atomically claim a file_id for this (filename, conversationId, context) tuple.
+ * Uses $setOnInsert so concurrent calls for the same filename converge on
+ * a single record instead of creating duplicates (TOCTOU race fix).
+ */
+ const newFileId = v4();
+ const claimed = await claimCodeFile({
filename: name,
conversationId,
+ file_id: newFileId,
user: req.user.id,
- type: `image/${appConfig.imageOutputType}`,
- createdAt: formattedDate,
+ });
+ const file_id = claimed.file_id;
+ const isUpdate = file_id !== newFileId;
+
+ if (isUpdate) {
+ logger.debug(
+ `[processCodeOutput] Updating existing file "${name}" (${file_id}) instead of creating duplicate`,
+ );
+ }
+
+ if (isImage) {
+ const usage = isUpdate ? (claimed.usage ?? 0) + 1 : 1;
+ const _file = await convertImage(req, buffer, 'high', `${file_id}${fileExt}`);
+ const filepath = usage > 1 ? `${_file.filepath}?v=${Date.now()}` : _file.filepath;
+ const file = {
+ ..._file,
+ filepath,
+ file_id,
+ messageId,
+ usage,
+ filename: name,
+ conversationId,
+ user: req.user.id,
+ type: `image/${appConfig.imageOutputType}`,
+ createdAt: isUpdate ? claimed.createdAt : formattedDate,
+ updatedAt: formattedDate,
+ source: appConfig.fileStrategy,
+ context: FileContext.execute_code,
+ metadata: { fileIdentifier },
+ };
+ await createFile(file, true);
+ return Object.assign(file, { messageId, toolCallId });
+ }
+
+ const { saveBuffer } = getStrategyFunctions(appConfig.fileStrategy);
+ if (!saveBuffer) {
+ logger.warn(
+ `[processCodeOutput] saveBuffer not available for strategy ${appConfig.fileStrategy}, falling back to download URL`,
+ );
+ return createDownloadFallback({
+ id,
+ name,
+ messageId,
+ toolCallId,
+ session_id,
+ conversationId,
+ expiresAt: currentDate.getTime() + 86400000,
+ });
+ }
+
+ const detectedType = await determineFileType(buffer, true);
+ const mimeType = detectedType?.mime || inferMimeType(name, '') || 'application/octet-stream';
+
+ /** Check MIME type support - for code-generated files, we're lenient but log unsupported types */
+ const isSupportedMimeType = fileConfig.checkType(
+ mimeType,
+ endpointFileConfig.supportedMimeTypes,
+ );
+ if (!isSupportedMimeType) {
+ logger.warn(
+ `[processCodeOutput] File "${name}" has unsupported MIME type "${mimeType}", proceeding with storage but may not be usable as tool resource`,
+ );
+ }
+
+ const fileName = `${file_id}__${name}`;
+ const filepath = await saveBuffer({
+ userId: req.user.id,
+ buffer,
+ fileName,
+ basePath: 'uploads',
+ });
+
+ const file = {
+ file_id,
+ filepath,
+ messageId,
+ object: 'file',
+ filename: name,
+ type: mimeType,
+ conversationId,
+ user: req.user.id,
+ bytes: buffer.length,
updatedAt: formattedDate,
+ metadata: { fileIdentifier },
source: appConfig.fileStrategy,
context: FileContext.execute_code,
+ usage: isUpdate ? (claimed.usage ?? 0) + 1 : 1,
+ createdAt: isUpdate ? claimed.createdAt : formattedDate,
};
- createFile(file, true);
- /** Note: `messageId` & `toolCallId` are not part of file DB schema; message object records associated file ID */
+
+ await createFile(file, true);
return Object.assign(file, { messageId, toolCallId });
} catch (error) {
logAxiosError({
- message: 'Error downloading code environment file',
+ message: 'Error downloading/processing code environment file',
error,
});
+
+ // Fallback for download errors - return download URL so user can still manually download
+ return createDownloadFallback({
+ id,
+ name,
+ messageId,
+ toolCallId,
+ session_id,
+ conversationId,
+ expiresAt: currentDate.getTime() + 86400000,
+ });
}
};
@@ -204,9 +355,16 @@ const primeFiles = async (options, apiKey) => {
if (!toolContext) {
toolContext = `- Note: The following files are available in the "${Tools.execute_code}" tool environment:`;
}
- toolContext += `\n\t- /mnt/data/${file.filename}${
- agentResourceIds.has(file.file_id) ? '' : ' (just attached by user)'
- }`;
+
+ let fileSuffix = '';
+ if (!agentResourceIds.has(file.file_id)) {
+ fileSuffix =
+ file.context === FileContext.execute_code
+ ? ' (from previous code execution)'
+ : ' (attached by user)';
+ }
+
+ toolContext += `\n\t- /mnt/data/${file.filename}${fileSuffix}`;
files.push({
id,
session_id,
diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js
new file mode 100644
index 0000000000..f01a623f90
--- /dev/null
+++ b/api/server/services/Files/Code/process.spec.js
@@ -0,0 +1,411 @@
+// Configurable file size limit for tests - use a getter so it can be changed per test
+const fileSizeLimitConfig = { value: 20 * 1024 * 1024 }; // Default 20MB
+
+// Mock librechat-data-provider with configurable file size limit
+jest.mock('librechat-data-provider', () => {
+ const actual = jest.requireActual('librechat-data-provider');
+ return {
+ ...actual,
+ mergeFileConfig: jest.fn((config) => {
+ const merged = actual.mergeFileConfig(config);
+ // Override the serverFileSizeLimit with our test value
+ return {
+ ...merged,
+ get serverFileSizeLimit() {
+ return fileSizeLimitConfig.value;
+ },
+ };
+ }),
+ getEndpointFileConfig: jest.fn((options) => {
+ const config = actual.getEndpointFileConfig(options);
+ // Override fileSizeLimit with our test value
+ return {
+ ...config,
+ get fileSizeLimit() {
+ return fileSizeLimitConfig.value;
+ },
+ };
+ }),
+ };
+});
+
+const { FileContext } = require('librechat-data-provider');
+
+// Mock uuid
+jest.mock('uuid', () => ({
+ v4: jest.fn(() => 'mock-uuid-1234'),
+}));
+
+// Mock axios
+jest.mock('axios');
+const axios = require('axios');
+
+// Mock logger
+jest.mock('@librechat/data-schemas', () => ({
+ logger: {
+ warn: jest.fn(),
+ debug: jest.fn(),
+ error: jest.fn(),
+ },
+}));
+
+// Mock getCodeBaseURL
+jest.mock('@librechat/agents', () => ({
+ getCodeBaseURL: jest.fn(() => 'https://code-api.example.com'),
+}));
+
+// Mock logAxiosError and getBasePath
+jest.mock('@librechat/api', () => ({
+ logAxiosError: jest.fn(),
+ getBasePath: jest.fn(() => ''),
+}));
+
+// Mock models
+const mockClaimCodeFile = jest.fn();
+jest.mock('~/models', () => ({
+ createFile: jest.fn().mockResolvedValue({}),
+ getFiles: jest.fn(),
+ updateFile: jest.fn(),
+ claimCodeFile: (...args) => mockClaimCodeFile(...args),
+}));
+
+// Mock permissions (must be before process.js import)
+jest.mock('~/server/services/Files/permissions', () => ({
+ filterFilesByAgentAccess: jest.fn((options) => Promise.resolve(options.files)),
+}));
+
+// Mock strategy functions
+jest.mock('~/server/services/Files/strategies', () => ({
+ getStrategyFunctions: jest.fn(),
+}));
+
+// Mock convertImage
+jest.mock('~/server/services/Files/images/convert', () => ({
+ convertImage: jest.fn(),
+}));
+
+// Mock determineFileType
+jest.mock('~/server/utils', () => ({
+ determineFileType: jest.fn(),
+}));
+
+const { createFile, getFiles } = require('~/models');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+const { convertImage } = require('~/server/services/Files/images/convert');
+const { determineFileType } = require('~/server/utils');
+const { logger } = require('@librechat/data-schemas');
+
+// Import after mocks
+const { processCodeOutput } = require('./process');
+
+describe('Code Process', () => {
+ const mockReq = {
+ user: { id: 'user-123' },
+ config: {
+ fileConfig: {},
+ fileStrategy: 'local',
+ imageOutputType: 'webp',
+ },
+ };
+
+ const baseParams = {
+ req: mockReq,
+ id: 'file-id-123',
+ name: 'test-file.txt',
+ apiKey: 'test-api-key',
+ toolCallId: 'tool-call-123',
+ conversationId: 'conv-123',
+ messageId: 'msg-123',
+ session_id: 'session-123',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ // Default mock: atomic claim returns a new file record (no existing file)
+ mockClaimCodeFile.mockResolvedValue({
+ file_id: 'mock-uuid-1234',
+ user: 'user-123',
+ });
+ getFiles.mockResolvedValue(null);
+ createFile.mockResolvedValue({});
+ getStrategyFunctions.mockReturnValue({
+ saveBuffer: jest.fn().mockResolvedValue('/uploads/mock-file-path.txt'),
+ });
+ determineFileType.mockResolvedValue({ mime: 'text/plain' });
+ });
+
+ describe('atomic file claim (via processCodeOutput)', () => {
+ it('should reuse file_id from existing record via atomic claim', async () => {
+ mockClaimCodeFile.mockResolvedValue({
+ file_id: 'existing-file-id',
+ filename: 'test-file.txt',
+ usage: 2,
+ createdAt: '2024-01-01T00:00:00.000Z',
+ });
+
+ const smallBuffer = Buffer.alloc(100);
+ axios.mockResolvedValue({ data: smallBuffer });
+
+ const result = await processCodeOutput(baseParams);
+
+ expect(mockClaimCodeFile).toHaveBeenCalledWith({
+ filename: 'test-file.txt',
+ conversationId: 'conv-123',
+ file_id: 'mock-uuid-1234',
+ user: 'user-123',
+ });
+
+ expect(result.file_id).toBe('existing-file-id');
+ expect(result.usage).toBe(3);
+ expect(result.createdAt).toBe('2024-01-01T00:00:00.000Z');
+ });
+
+ it('should create new file when no existing file found', async () => {
+ mockClaimCodeFile.mockResolvedValue({
+ file_id: 'mock-uuid-1234',
+ user: 'user-123',
+ });
+
+ const smallBuffer = Buffer.alloc(100);
+ axios.mockResolvedValue({ data: smallBuffer });
+
+ const result = await processCodeOutput(baseParams);
+
+ expect(result.file_id).toBe('mock-uuid-1234');
+ expect(result.usage).toBe(1);
+ });
+ });
+
+ describe('processCodeOutput', () => {
+ describe('image file processing', () => {
+ it('should process image files using convertImage', async () => {
+ const imageParams = { ...baseParams, name: 'chart.png' };
+ const imageBuffer = Buffer.alloc(500);
+ axios.mockResolvedValue({ data: imageBuffer });
+
+ const convertedFile = {
+ filepath: '/uploads/converted-image.webp',
+ bytes: 400,
+ };
+ convertImage.mockResolvedValue(convertedFile);
+
+ const result = await processCodeOutput(imageParams);
+
+ expect(convertImage).toHaveBeenCalledWith(
+ mockReq,
+ imageBuffer,
+ 'high',
+ 'mock-uuid-1234.png',
+ );
+ expect(result.type).toBe('image/webp');
+ expect(result.context).toBe(FileContext.execute_code);
+ expect(result.filename).toBe('chart.png');
+ });
+
+ it('should update existing image file with cache-busted filepath', async () => {
+ const imageParams = { ...baseParams, name: 'chart.png' };
+ mockClaimCodeFile.mockResolvedValue({
+ file_id: 'existing-img-id',
+ usage: 1,
+ createdAt: '2024-01-01T00:00:00.000Z',
+ });
+
+ const imageBuffer = Buffer.alloc(500);
+ axios.mockResolvedValue({ data: imageBuffer });
+ convertImage.mockResolvedValue({ filepath: '/images/user-123/existing-img-id.webp' });
+
+ const result = await processCodeOutput(imageParams);
+
+ expect(convertImage).toHaveBeenCalledWith(
+ mockReq,
+ imageBuffer,
+ 'high',
+ 'existing-img-id.png',
+ );
+ expect(result.file_id).toBe('existing-img-id');
+ expect(result.usage).toBe(2);
+ expect(result.filepath).toMatch(/^\/images\/user-123\/existing-img-id\.webp\?v=\d+$/);
+ expect(logger.debug).toHaveBeenCalledWith(
+ expect.stringContaining('Updating existing file'),
+ );
+ });
+ });
+
+ describe('non-image file processing', () => {
+ it('should process non-image files using saveBuffer', async () => {
+ const smallBuffer = Buffer.alloc(100);
+ axios.mockResolvedValue({ data: smallBuffer });
+
+ const mockSaveBuffer = jest.fn().mockResolvedValue('/uploads/saved-file.txt');
+ getStrategyFunctions.mockReturnValue({ saveBuffer: mockSaveBuffer });
+ determineFileType.mockResolvedValue({ mime: 'text/plain' });
+
+ const result = await processCodeOutput(baseParams);
+
+ expect(mockSaveBuffer).toHaveBeenCalledWith({
+ userId: 'user-123',
+ buffer: smallBuffer,
+ fileName: 'mock-uuid-1234__test-file.txt',
+ basePath: 'uploads',
+ });
+ expect(result.type).toBe('text/plain');
+ expect(result.filepath).toBe('/uploads/saved-file.txt');
+ expect(result.bytes).toBe(100);
+ });
+
+ it('should detect MIME type from buffer', async () => {
+ const smallBuffer = Buffer.alloc(100);
+ axios.mockResolvedValue({ data: smallBuffer });
+ determineFileType.mockResolvedValue({ mime: 'application/pdf' });
+
+ const result = await processCodeOutput({ ...baseParams, name: 'document.pdf' });
+
+ expect(determineFileType).toHaveBeenCalledWith(smallBuffer, true);
+ expect(result.type).toBe('application/pdf');
+ });
+
+ it('should fallback to application/octet-stream for unknown types', async () => {
+ const smallBuffer = Buffer.alloc(100);
+ axios.mockResolvedValue({ data: smallBuffer });
+ determineFileType.mockResolvedValue(null);
+
+ const result = await processCodeOutput({ ...baseParams, name: 'unknown.xyz' });
+
+ expect(result.type).toBe('application/octet-stream');
+ });
+ });
+
+ describe('file size limit enforcement', () => {
+ it('should fallback to download URL when file exceeds size limit', async () => {
+ // Set a small file size limit for this test
+ fileSizeLimitConfig.value = 1000; // 1KB limit
+
+ const largeBuffer = Buffer.alloc(5000); // 5KB - exceeds 1KB limit
+ axios.mockResolvedValue({ data: largeBuffer });
+
+ const result = await processCodeOutput(baseParams);
+
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('exceeds size limit'));
+ expect(result.filepath).toContain('/api/files/code/download/session-123/file-id-123');
+ expect(result.expiresAt).toBeDefined();
+ // Should not call createFile for oversized files (fallback path)
+ expect(createFile).not.toHaveBeenCalled();
+
+ // Reset to default for other tests
+ fileSizeLimitConfig.value = 20 * 1024 * 1024;
+ });
+ });
+
+ describe('fallback behavior', () => {
+ it('should fallback to download URL when saveBuffer is not available', async () => {
+ const smallBuffer = Buffer.alloc(100);
+ axios.mockResolvedValue({ data: smallBuffer });
+ getStrategyFunctions.mockReturnValue({ saveBuffer: null });
+
+ const result = await processCodeOutput(baseParams);
+
+ expect(logger.warn).toHaveBeenCalledWith(
+ expect.stringContaining('saveBuffer not available'),
+ );
+ expect(result.filepath).toContain('/api/files/code/download/');
+ expect(result.filename).toBe('test-file.txt');
+ });
+
+ it('should fallback to download URL on axios error', async () => {
+ axios.mockRejectedValue(new Error('Network error'));
+
+ const result = await processCodeOutput(baseParams);
+
+ expect(result.filepath).toContain('/api/files/code/download/session-123/file-id-123');
+ expect(result.conversationId).toBe('conv-123');
+ expect(result.messageId).toBe('msg-123');
+ expect(result.toolCallId).toBe('tool-call-123');
+ });
+ });
+
+ describe('usage counter increment', () => {
+ it('should set usage to 1 for new files', async () => {
+ const smallBuffer = Buffer.alloc(100);
+ axios.mockResolvedValue({ data: smallBuffer });
+
+ const result = await processCodeOutput(baseParams);
+
+ expect(result.usage).toBe(1);
+ });
+
+ it('should increment usage for existing files', async () => {
+ mockClaimCodeFile.mockResolvedValue({
+ file_id: 'existing-id',
+ usage: 5,
+ createdAt: '2024-01-01',
+ });
+ const smallBuffer = Buffer.alloc(100);
+ axios.mockResolvedValue({ data: smallBuffer });
+
+ const result = await processCodeOutput(baseParams);
+
+ expect(result.usage).toBe(6);
+ });
+
+ it('should handle existing file with undefined usage', async () => {
+ mockClaimCodeFile.mockResolvedValue({
+ file_id: 'existing-id',
+ createdAt: '2024-01-01',
+ });
+ const smallBuffer = Buffer.alloc(100);
+ axios.mockResolvedValue({ data: smallBuffer });
+
+ const result = await processCodeOutput(baseParams);
+
+ expect(result.usage).toBe(1);
+ });
+ });
+
+ describe('metadata and file properties', () => {
+ it('should include fileIdentifier in metadata', async () => {
+ const smallBuffer = Buffer.alloc(100);
+ axios.mockResolvedValue({ data: smallBuffer });
+
+ const result = await processCodeOutput(baseParams);
+
+ expect(result.metadata).toEqual({
+ fileIdentifier: 'session-123/file-id-123',
+ });
+ });
+
+ it('should set correct context for code-generated files', async () => {
+ const smallBuffer = Buffer.alloc(100);
+ axios.mockResolvedValue({ data: smallBuffer });
+
+ const result = await processCodeOutput(baseParams);
+
+ expect(result.context).toBe(FileContext.execute_code);
+ });
+
+ it('should include toolCallId and messageId in result', async () => {
+ const smallBuffer = Buffer.alloc(100);
+ axios.mockResolvedValue({ data: smallBuffer });
+
+ const result = await processCodeOutput(baseParams);
+
+ expect(result.toolCallId).toBe('tool-call-123');
+ expect(result.messageId).toBe('msg-123');
+ });
+
+ it('should call createFile with upsert enabled', async () => {
+ const smallBuffer = Buffer.alloc(100);
+ axios.mockResolvedValue({ data: smallBuffer });
+
+ await processCodeOutput(baseParams);
+
+ expect(createFile).toHaveBeenCalledWith(
+ expect.objectContaining({
+ file_id: 'mock-uuid-1234',
+ context: FileContext.execute_code,
+ }),
+ true, // upsert flag
+ );
+ });
+ });
+ });
+});
diff --git a/api/server/services/Files/Firebase/crud.js b/api/server/services/Files/Firebase/crud.js
index 8e7a191609..d5e5a409bf 100644
--- a/api/server/services/Files/Firebase/crud.js
+++ b/api/server/services/Files/Firebase/crud.js
@@ -3,7 +3,7 @@ const path = require('path');
const axios = require('axios');
const fetch = require('node-fetch');
const { logger } = require('@librechat/data-schemas');
-const { getFirebaseStorage } = require('@librechat/api');
+const { getFirebaseStorage, deleteRagFile } = require('@librechat/api');
const { ref, uploadBytes, getDownloadURL, deleteObject } = require('firebase/storage');
const { getBufferMetadata } = require('~/server/utils');
@@ -167,17 +167,7 @@ function extractFirebaseFilePath(urlString) {
* Throws an error if there is an issue with deletion.
*/
const deleteFirebaseFile = async (req, file) => {
- if (file.embedded && process.env.RAG_API_URL) {
- const jwtToken = req.headers.authorization.split(' ')[1];
- axios.delete(`${process.env.RAG_API_URL}/documents`, {
- headers: {
- Authorization: `Bearer ${jwtToken}`,
- 'Content-Type': 'application/json',
- accept: 'application/json',
- },
- data: [file.file_id],
- });
- }
+ await deleteRagFile({ userId: req.user.id, file });
const fileName = extractFirebaseFilePath(file.filepath);
if (!fileName.includes(req.user.id)) {
diff --git a/api/server/services/Files/Local/crud.js b/api/server/services/Files/Local/crud.js
index d3a3a21538..1f38a01f83 100644
--- a/api/server/services/Files/Local/crud.js
+++ b/api/server/services/Files/Local/crud.js
@@ -1,9 +1,9 @@
const fs = require('fs');
const path = require('path');
const axios = require('axios');
+const { deleteRagFile } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { EModelEndpoint } = require('librechat-data-provider');
-const { generateShortLivedToken } = require('@librechat/api');
const { resizeImageBuffer } = require('~/server/services/Files/images/resize');
const { getBufferMetadata } = require('~/server/utils');
const paths = require('~/config/paths');
@@ -67,7 +67,12 @@ async function saveLocalBuffer({ userId, buffer, fileName, basePath = 'images' }
try {
const { publicPath, uploads } = paths;
- const directoryPath = path.join(basePath === 'images' ? publicPath : uploads, basePath, userId);
+ /**
+ * For 'images': save to publicPath/images/userId (images are served statically)
+ * For 'uploads': save to uploads/userId (files downloaded via API)
+ * */
+ const directoryPath =
+ basePath === 'images' ? path.join(publicPath, basePath, userId) : path.join(uploads, userId);
if (!fs.existsSync(directoryPath)) {
fs.mkdirSync(directoryPath, { recursive: true });
@@ -208,17 +213,7 @@ const deleteLocalFile = async (req, file) => {
/** Filepath stripped of query parameters (e.g., ?manual=true) */
const cleanFilepath = file.filepath.split('?')[0];
- if (file.embedded && process.env.RAG_API_URL) {
- const jwtToken = generateShortLivedToken(req.user.id);
- axios.delete(`${process.env.RAG_API_URL}/documents`, {
- headers: {
- Authorization: `Bearer ${jwtToken}`,
- 'Content-Type': 'application/json',
- accept: 'application/json',
- },
- data: [file.file_id],
- });
- }
+ await deleteRagFile({ userId: req.user.id, file });
if (cleanFilepath.startsWith(`/uploads/${req.user.id}`)) {
const userUploadDir = path.join(uploads, req.user.id);
diff --git a/api/server/services/Files/S3/crud.js b/api/server/services/Files/S3/crud.js
index 8dac767aa2..c821c0696c 100644
--- a/api/server/services/Files/S3/crud.js
+++ b/api/server/services/Files/S3/crud.js
@@ -1,9 +1,9 @@
const fs = require('fs');
const fetch = require('node-fetch');
-const { initializeS3 } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { FileSources } = require('librechat-data-provider');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
+const { initializeS3, deleteRagFile, isEnabled } = require('@librechat/api');
const {
PutObjectCommand,
GetObjectCommand,
@@ -13,6 +13,8 @@ const {
const bucketName = process.env.AWS_BUCKET_NAME;
const defaultBasePath = 'images';
+const endpoint = process.env.AWS_ENDPOINT_URL;
+const forcePathStyle = isEnabled(process.env.AWS_FORCE_PATH_STYLE);
let s3UrlExpirySeconds = 2 * 60; // 2 minutes
let s3RefreshExpiryMs = null;
@@ -142,6 +144,8 @@ async function saveURLToS3({ userId, URL, fileName, basePath = defaultBasePath }
* @returns {Promise}
*/
async function deleteFileFromS3(req, file) {
+ await deleteRagFile({ userId: req.user.id, file });
+
const key = extractKeyFromS3Url(file.filepath);
const params = { Bucket: bucketName, Key: key };
if (!key.includes(req.user.id)) {
@@ -250,15 +254,83 @@ function extractKeyFromS3Url(fileUrlOrKey) {
try {
const url = new URL(fileUrlOrKey);
- return url.pathname.substring(1);
+ const hostname = url.hostname;
+ const pathname = url.pathname.substring(1); // Remove leading slash
+
+ // Explicit path-style with custom endpoint: use endpoint pathname for precise key extraction.
+ // Handles endpoints with a base path (e.g. https://example.com/storage/).
+ if (endpoint && forcePathStyle) {
+ const endpointUrl = new URL(endpoint);
+ const startPos =
+ endpointUrl.pathname.length +
+ (endpointUrl.pathname.endsWith('/') ? 0 : 1) +
+ bucketName.length +
+ 1;
+ const key = url.pathname.substring(startPos);
+ if (!key) {
+ logger.warn(
+ `[extractKeyFromS3Url] Extracted key is empty for endpoint path-style URL: ${fileUrlOrKey}`,
+ );
+ } else {
+ logger.debug(`[extractKeyFromS3Url] fileUrlOrKey: ${fileUrlOrKey}, Extracted key: ${key}`);
+ }
+ return key;
+ }
+
+ if (
+ hostname === 's3.amazonaws.com' ||
+ hostname.match(/^s3[-.][a-z0-9-]+\.amazonaws\.com$/) ||
+ (bucketName && pathname.startsWith(`${bucketName}/`))
+ ) {
+ // Path-style: https://s3.amazonaws.com/bucket-name/key or custom endpoint (MinIO, R2, etc.)
+ // Strip the bucket name (first path segment)
+ const firstSlashIndex = pathname.indexOf('/');
+ if (firstSlashIndex > 0) {
+ const key = pathname.substring(firstSlashIndex + 1);
+
+ if (key === '') {
+ logger.warn(
+ `[extractKeyFromS3Url] Extracted key is empty after removing bucket name from URL: ${fileUrlOrKey}`,
+ );
+ } else {
+ logger.debug(
+ `[extractKeyFromS3Url] fileUrlOrKey: ${fileUrlOrKey}, Extracted key: ${key}`,
+ );
+ }
+
+ return key;
+ } else {
+ logger.warn(
+ `[extractKeyFromS3Url] Unable to extract key from path-style URL: ${fileUrlOrKey}`,
+ );
+ return '';
+ }
+ }
+
+ // Virtual-hosted-style or other: https://bucket-name.s3.amazonaws.com/key
+ // Just return the pathname without leading slash
+ logger.debug(`[extractKeyFromS3Url] fileUrlOrKey: ${fileUrlOrKey}, Extracted key: ${pathname}`);
+ return pathname;
} catch (error) {
+ if (fileUrlOrKey.startsWith('http://') || fileUrlOrKey.startsWith('https://')) {
+ logger.error(
+ `[extractKeyFromS3Url] Error parsing URL: ${fileUrlOrKey}, Error: ${error.message}`,
+ );
+ } else {
+ logger.debug(`[extractKeyFromS3Url] Non-URL input, using fallback: ${fileUrlOrKey}`);
+ }
+
const parts = fileUrlOrKey.split('/');
if (parts.length >= 3 && !fileUrlOrKey.startsWith('http') && !fileUrlOrKey.startsWith('/')) {
return fileUrlOrKey;
}
- return fileUrlOrKey.startsWith('/') ? fileUrlOrKey.substring(1) : fileUrlOrKey;
+ const key = fileUrlOrKey.startsWith('/') ? fileUrlOrKey.substring(1) : fileUrlOrKey;
+ logger.debug(
+ `[extractKeyFromS3Url] FALLBACK. fileUrlOrKey: ${fileUrlOrKey}, Extracted key: ${key}`,
+ );
+ return key;
}
}
@@ -480,4 +552,5 @@ module.exports = {
refreshS3Url,
needsRefresh,
getNewS3URL,
+ extractKeyFromS3Url,
};
diff --git a/api/server/services/Files/images/encode.js b/api/server/services/Files/images/encode.js
index e719e58af3..93d0aebd4b 100644
--- a/api/server/services/Files/images/encode.js
+++ b/api/server/services/Files/images/encode.js
@@ -80,7 +80,7 @@ const base64Only = new Set([
EModelEndpoint.bedrock,
]);
-const blobStorageSources = new Set([FileSources.azure_blob, FileSources.s3]);
+const blobStorageSources = new Set([FileSources.azure_blob, FileSources.s3, FileSources.firebase]);
/**
* Encodes and formats the given files.
@@ -127,7 +127,7 @@ async function encodeAndFormat(req, files, params, mode) {
}
const preparePayload = encodingMethods[source].prepareImagePayload;
- /* We need to fetch the image and convert it to base64 if we are using S3/Azure Blob storage. */
+ /* We need to fetch the image and convert it to base64 if we are using S3/Azure Blob/Firebase storage. */
if (blobStorageSources.has(source)) {
try {
const downloadStream = encodingMethods[source].getDownloadStream;
diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js
index f586554ae8..d01128927a 100644
--- a/api/server/services/Files/process.js
+++ b/api/server/services/Files/process.js
@@ -16,6 +16,7 @@ const {
removeNullishValues,
isAssistantsEndpoint,
getEndpointFileConfig,
+ documentParserMimeTypes,
} = require('librechat-data-provider');
const { EnvVar } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
@@ -28,8 +29,8 @@ const {
const { addResourceFileId, deleteResourceFileId } = require('~/server/controllers/assistants/v2');
const { addAgentResourceFile, removeAgentResourceFiles } = require('~/models/Agent');
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
-const { createFile, updateFileUsage, deleteFiles } = require('~/models/File');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
+const { createFile, updateFileUsage, deleteFiles } = require('~/models');
const { getFileStrategy } = require('~/server/utils/getFileStrategy');
const { checkCapability } = require('~/server/services/Config');
const { LB_QueueAsyncCall } = require('~/server/utils/queue');
@@ -60,45 +61,6 @@ const createSanitizedUploadWrapper = (uploadFunction) => {
};
};
-/**
- *
- * @param {Array} files
- * @param {Array} [fileIds]
- * @returns
- */
-const processFiles = async (files, fileIds) => {
- const promises = [];
- const seen = new Set();
-
- for (let file of files) {
- const { file_id } = file;
- if (seen.has(file_id)) {
- continue;
- }
- seen.add(file_id);
- promises.push(updateFileUsage({ file_id }));
- }
-
- if (!fileIds) {
- const results = await Promise.all(promises);
- // Filter out null results from failed updateFileUsage calls
- return results.filter((result) => result != null);
- }
-
- for (let file_id of fileIds) {
- if (seen.has(file_id)) {
- continue;
- }
- seen.add(file_id);
- promises.push(updateFileUsage({ file_id }));
- }
-
- // TODO: calculate token cost when image is first uploaded
- const results = await Promise.all(promises);
- // Filter out null results from failed updateFileUsage calls
- return results.filter((result) => result != null);
-};
-
/**
* Enqueues the delete operation to the leaky bucket queue if necessary, or adds it directly to promises.
*
@@ -562,6 +524,12 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
* @return {Promise}
*/
const createTextFile = async ({ text, bytes, filepath, type = 'text/plain' }) => {
+ const textBytes = Buffer.byteLength(text, 'utf8');
+ if (textBytes > 15 * megabyte) {
+ throw new Error(
+ `Extracted text from "${file.originalname}" exceeds the 15MB storage limit (${Math.round(textBytes / megabyte)}MB). Try a shorter document.`,
+ );
+ }
const fileInfo = removeNullishValues({
text,
bytes,
@@ -592,29 +560,52 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
const fileConfig = mergeFileConfig(appConfig.fileConfig);
- const shouldUseOCR =
+ const shouldUseConfiguredOCR =
appConfig?.ocr != null &&
fileConfig.checkType(file.mimetype, fileConfig.ocr?.supportedMimeTypes || []);
- if (shouldUseOCR && !(await checkCapability(req, AgentCapabilities.ocr))) {
- throw new Error('OCR capability is not enabled for Agents');
- } else if (shouldUseOCR) {
+ const shouldUseDocumentParser =
+ !shouldUseConfiguredOCR && documentParserMimeTypes.some((regex) => regex.test(file.mimetype));
+
+ const shouldUseOCR = shouldUseConfiguredOCR || shouldUseDocumentParser;
+
+ const resolveDocumentText = async () => {
+ if (shouldUseConfiguredOCR) {
+ try {
+ const ocrStrategy = appConfig?.ocr?.strategy ?? FileSources.document_parser;
+ const { handleFileUpload } = getStrategyFunctions(ocrStrategy);
+ return await handleFileUpload({ req, file, loadAuthValues });
+ } catch (err) {
+ logger.error(
+ `[processAgentFileUpload] Configured OCR failed for "${file.originalname}", falling back to document_parser:`,
+ err,
+ );
+ }
+ }
try {
- const { handleFileUpload: uploadOCR } = getStrategyFunctions(
- appConfig?.ocr?.strategy ?? FileSources.mistral_ocr,
- );
- const {
- text,
- bytes,
- filepath: ocrFileURL,
- } = await uploadOCR({ req, file, loadAuthValues });
- return await createTextFile({ text, bytes, filepath: ocrFileURL });
- } catch (ocrError) {
+ const { handleFileUpload } = getStrategyFunctions(FileSources.document_parser);
+ return await handleFileUpload({ req, file, loadAuthValues });
+ } catch (err) {
logger.error(
- `[processAgentFileUpload] OCR processing failed for file "${file.originalname}", falling back to text extraction:`,
- ocrError,
+ `[processAgentFileUpload] Document parser failed for "${file.originalname}":`,
+ err,
);
}
+ };
+
+ if (shouldUseConfiguredOCR && !(await checkCapability(req, AgentCapabilities.ocr))) {
+ throw new Error('OCR capability is not enabled for Agents');
+ }
+
+ if (shouldUseOCR) {
+ const ocrResult = await resolveDocumentText();
+ if (ocrResult) {
+ const { text, bytes, filepath: ocrFileURL } = ocrResult;
+ return await createTextFile({ text, bytes, filepath: ocrFileURL });
+ }
+ throw new Error(
+ `Unable to extract text from "${file.originalname}". The document may be image-based and requires an OCR service to process.`,
+ );
}
const shouldUseSTT = fileConfig.checkType(
@@ -1057,7 +1048,6 @@ function filterFile({ req, image, isAvatar }) {
module.exports = {
filterFile,
- processFiles,
processFileURL,
saveBase64Image,
processImageFile,
diff --git a/api/server/services/Files/process.spec.js b/api/server/services/Files/process.spec.js
new file mode 100644
index 0000000000..7737255a52
--- /dev/null
+++ b/api/server/services/Files/process.spec.js
@@ -0,0 +1,347 @@
+jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: { warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
+}));
+
+jest.mock('@librechat/agents', () => ({
+ EnvVar: { CODE_API_KEY: 'CODE_API_KEY' },
+}));
+
+jest.mock('@librechat/api', () => ({
+ sanitizeFilename: jest.fn((n) => n),
+ parseText: jest.fn().mockResolvedValue({ text: '', bytes: 0 }),
+ processAudioFile: jest.fn(),
+}));
+
+jest.mock('librechat-data-provider', () => ({
+ ...jest.requireActual('librechat-data-provider'),
+ mergeFileConfig: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/images', () => ({
+ convertImage: jest.fn(),
+ resizeAndConvert: jest.fn(),
+ resizeImageBuffer: jest.fn(),
+}));
+
+jest.mock('~/server/controllers/assistants/v2', () => ({
+ addResourceFileId: jest.fn(),
+ deleteResourceFileId: jest.fn(),
+}));
+
+jest.mock('~/models/Agent', () => ({
+ addAgentResourceFile: jest.fn().mockResolvedValue({}),
+ removeAgentResourceFiles: jest.fn(),
+}));
+
+jest.mock('~/server/controllers/assistants/helpers', () => ({
+ getOpenAIClient: jest.fn(),
+}));
+
+jest.mock('~/server/services/Tools/credentials', () => ({
+ loadAuthValues: jest.fn(),
+}));
+
+jest.mock('~/models', () => ({
+ createFile: jest.fn().mockResolvedValue({ file_id: 'created-file-id' }),
+ updateFileUsage: jest.fn(),
+ deleteFiles: jest.fn(),
+}));
+
+jest.mock('~/server/utils/getFileStrategy', () => ({
+ getFileStrategy: jest.fn().mockReturnValue('local'),
+}));
+
+jest.mock('~/server/services/Config', () => ({
+ checkCapability: jest.fn().mockResolvedValue(true),
+}));
+
+jest.mock('~/server/utils/queue', () => ({
+ LB_QueueAsyncCall: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/strategies', () => ({
+ getStrategyFunctions: jest.fn(),
+}));
+
+jest.mock('~/server/utils', () => ({
+ determineFileType: jest.fn(),
+}));
+
+jest.mock('~/server/services/Files/Audio/STTService', () => ({
+ STTService: { getInstance: jest.fn() },
+}));
+
+const { EToolResources, FileSources, AgentCapabilities } = require('librechat-data-provider');
+const { mergeFileConfig } = require('librechat-data-provider');
+const { checkCapability } = require('~/server/services/Config');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+const { processAgentFileUpload } = require('./process');
+
+const PDF_MIME = 'application/pdf';
+const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
+const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
+const XLS_MIME = 'application/vnd.ms-excel';
+const ODS_MIME = 'application/vnd.oasis.opendocument.spreadsheet';
+const ODT_MIME = 'application/vnd.oasis.opendocument.text';
+const ODP_MIME = 'application/vnd.oasis.opendocument.presentation';
+const ODG_MIME = 'application/vnd.oasis.opendocument.graphics';
+
+const makeReq = ({ mimetype = PDF_MIME, ocrConfig = null } = {}) => ({
+ user: { id: 'user-123' },
+ file: {
+ path: '/tmp/upload.bin',
+ originalname: 'upload.bin',
+ filename: 'upload-uuid.bin',
+ mimetype,
+ },
+ body: { model: 'gpt-4o' },
+ config: {
+ fileConfig: {},
+ fileStrategy: 'local',
+ ocr: ocrConfig,
+ },
+});
+
+const makeMetadata = () => ({
+ agent_id: 'agent-abc',
+ tool_resource: EToolResources.context,
+ file_id: 'file-uuid-123',
+});
+
+const mockRes = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnValue({}),
+};
+
+const makeFileConfig = ({ ocrSupportedMimeTypes = [] } = {}) => ({
+ checkType: (mime, types) => (types ?? []).includes(mime),
+ ocr: { supportedMimeTypes: ocrSupportedMimeTypes },
+ stt: { supportedMimeTypes: [] },
+ text: { supportedMimeTypes: [] },
+});
+
+describe('processAgentFileUpload', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockRes.status.mockReturnThis();
+ mockRes.json.mockReturnValue({});
+ checkCapability.mockResolvedValue(true);
+ getStrategyFunctions.mockReturnValue({
+ handleFileUpload: jest
+ .fn()
+ .mockResolvedValue({ text: 'extracted text', bytes: 42, filepath: 'doc://result' }),
+ });
+ mergeFileConfig.mockReturnValue(makeFileConfig());
+ });
+
+ describe('OCR strategy selection', () => {
+ test.each([
+ ['PDF', PDF_MIME],
+ ['DOCX', DOCX_MIME],
+ ['XLSX', XLSX_MIME],
+ ['XLS', XLS_MIME],
+ ['ODS', ODS_MIME],
+ ['Excel variant (msexcel)', 'application/msexcel'],
+ ['Excel variant (x-msexcel)', 'application/x-msexcel'],
+ ])('uses document_parser automatically for %s when no OCR is configured', async (_, mime) => {
+ mergeFileConfig.mockReturnValue(makeFileConfig());
+ const req = makeReq({ mimetype: mime, ocrConfig: null });
+
+ await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
+
+ expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
+ });
+
+ test('does not check OCR capability when using automatic document_parser fallback', async () => {
+ const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null });
+
+ await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
+
+ expect(checkCapability).not.toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
+ expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
+ });
+
+ test('uses the configured OCR strategy when OCR is set up for the file type', async () => {
+ mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
+ const req = makeReq({
+ mimetype: PDF_MIME,
+ ocrConfig: { strategy: FileSources.mistral_ocr },
+ });
+
+ await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
+
+ expect(checkCapability).toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
+ expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.mistral_ocr);
+ });
+
+ test('uses document_parser as default when OCR is configured but no strategy is specified', async () => {
+ mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
+ const req = makeReq({
+ mimetype: PDF_MIME,
+ ocrConfig: { supportedMimeTypes: [PDF_MIME] },
+ });
+
+ await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
+
+ expect(checkCapability).toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
+ expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
+ });
+
+ test('throws when configured OCR capability is not enabled for the agent', async () => {
+ mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
+ checkCapability.mockResolvedValue(false);
+ const req = makeReq({
+ mimetype: PDF_MIME,
+ ocrConfig: { strategy: FileSources.mistral_ocr },
+ });
+
+ await expect(
+ processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
+ ).rejects.toThrow('OCR capability is not enabled for Agents');
+ });
+
+ test('uses document_parser (no capability check) when OCR capability returns false but no OCR config', async () => {
+ checkCapability.mockResolvedValue(false);
+ const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null });
+
+ await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
+
+ expect(checkCapability).not.toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
+ expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
+ });
+
+ test('uses document_parser when OCR is configured but the file type is not in OCR supported types', async () => {
+ mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
+ const req = makeReq({
+ mimetype: DOCX_MIME,
+ ocrConfig: { strategy: FileSources.mistral_ocr },
+ });
+
+ await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
+
+ expect(checkCapability).not.toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
+ expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
+ expect(getStrategyFunctions).not.toHaveBeenCalledWith(FileSources.mistral_ocr);
+ });
+
+ test('does not invoke any OCR strategy for unsupported MIME types without OCR config', async () => {
+ const req = makeReq({ mimetype: 'text/plain', ocrConfig: null });
+
+ await expect(
+ processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
+ ).rejects.toThrow('File type text/plain is not supported for text parsing.');
+
+ expect(getStrategyFunctions).not.toHaveBeenCalled();
+ });
+
+ test.each([
+ ['ODT', ODT_MIME],
+ ['ODP', ODP_MIME],
+ ['ODG', ODG_MIME],
+ ])('routes %s through configured OCR when OCR supports the type', async (_, mime) => {
+ mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [mime] }));
+ const req = makeReq({
+ mimetype: mime,
+ ocrConfig: { strategy: FileSources.mistral_ocr },
+ });
+
+ await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
+
+ expect(checkCapability).toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
+ expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.mistral_ocr);
+ });
+
+ test('throws instead of falling back to parseText when document_parser fails for a document MIME type', async () => {
+ getStrategyFunctions.mockReturnValue({
+ handleFileUpload: jest.fn().mockRejectedValue(new Error('No text found in document')),
+ });
+ const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null });
+ const { parseText } = require('@librechat/api');
+
+ await expect(
+ processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
+ ).rejects.toThrow(/image-based and requires an OCR service/);
+
+ expect(parseText).not.toHaveBeenCalled();
+ });
+
+ test('falls back to document_parser when configured OCR fails for a document MIME type', async () => {
+ mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
+ const failingUpload = jest.fn().mockRejectedValue(new Error('OCR API returned 500'));
+ const fallbackUpload = jest
+ .fn()
+ .mockResolvedValue({ text: 'parsed text', bytes: 11, filepath: 'doc://result' });
+ getStrategyFunctions
+ .mockReturnValueOnce({ handleFileUpload: failingUpload })
+ .mockReturnValueOnce({ handleFileUpload: fallbackUpload });
+ const req = makeReq({
+ mimetype: PDF_MIME,
+ ocrConfig: { strategy: FileSources.mistral_ocr },
+ });
+
+ await expect(
+ processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
+ ).resolves.not.toThrow();
+
+ expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.mistral_ocr);
+ expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
+ });
+
+ test('throws when both configured OCR and document_parser fallback fail', async () => {
+ mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
+ getStrategyFunctions.mockReturnValue({
+ handleFileUpload: jest.fn().mockRejectedValue(new Error('failure')),
+ });
+ const req = makeReq({
+ mimetype: PDF_MIME,
+ ocrConfig: { strategy: FileSources.mistral_ocr },
+ });
+ const { parseText } = require('@librechat/api');
+
+ await expect(
+ processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
+ ).rejects.toThrow(/image-based and requires an OCR service/);
+
+ expect(parseText).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('text size guard', () => {
+ test('throws before writing to MongoDB when extracted text exceeds 15MB', async () => {
+ const oversizedText = 'x'.repeat(15 * 1024 * 1024 + 1);
+ getStrategyFunctions.mockReturnValue({
+ handleFileUpload: jest.fn().mockResolvedValue({
+ text: oversizedText,
+ bytes: Buffer.byteLength(oversizedText, 'utf8'),
+ filepath: 'doc://result',
+ }),
+ });
+ const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null });
+ const { createFile } = require('~/models');
+
+ await expect(
+ processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
+ ).rejects.toThrow(/exceeds the 15MB storage limit/);
+
+ expect(createFile).not.toHaveBeenCalled();
+ });
+
+ test('succeeds when extracted text is within the 15MB limit', async () => {
+ const okText = 'x'.repeat(1024);
+ getStrategyFunctions.mockReturnValue({
+ handleFileUpload: jest.fn().mockResolvedValue({
+ text: okText,
+ bytes: Buffer.byteLength(okText, 'utf8'),
+ filepath: 'doc://result',
+ }),
+ });
+ const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null });
+
+ await expect(
+ processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
+ ).resolves.not.toThrow();
+ });
+ });
+});
diff --git a/api/server/services/Files/processFiles.test.js b/api/server/services/Files/processFiles.test.js
deleted file mode 100644
index 8417f639e9..0000000000
--- a/api/server/services/Files/processFiles.test.js
+++ /dev/null
@@ -1,248 +0,0 @@
-// Mock the updateFileUsage function before importing the actual processFiles
-jest.mock('~/models/File', () => ({
- updateFileUsage: jest.fn(),
-}));
-
-// Mock winston and logger configuration to avoid dependency issues
-jest.mock('~/config', () => ({
- logger: {
- info: jest.fn(),
- warn: jest.fn(),
- debug: jest.fn(),
- error: jest.fn(),
- },
-}));
-
-// Mock all other dependencies that might cause issues
-jest.mock('librechat-data-provider', () => ({
- isUUID: { parse: jest.fn() },
- megabyte: 1024 * 1024,
- PrincipalType: {
- USER: 'user',
- GROUP: 'group',
- PUBLIC: 'public',
- },
- PrincipalModel: {
- USER: 'User',
- GROUP: 'Group',
- },
- ResourceType: {
- AGENT: 'agent',
- PROJECT: 'project',
- FILE: 'file',
- PROMPTGROUP: 'promptGroup',
- },
- FileContext: { message_attachment: 'message_attachment' },
- FileSources: { local: 'local' },
- EModelEndpoint: { assistants: 'assistants' },
- EToolResources: { file_search: 'file_search' },
- mergeFileConfig: jest.fn(),
- removeNullishValues: jest.fn((obj) => obj),
- isAssistantsEndpoint: jest.fn(),
- Constants: { COMMANDS_MAX_LENGTH: 56 },
- PermissionTypes: {
- BOOKMARKS: 'BOOKMARKS',
- PROMPTS: 'PROMPTS',
- MEMORIES: 'MEMORIES',
- MULTI_CONVO: 'MULTI_CONVO',
- AGENTS: 'AGENTS',
- TEMPORARY_CHAT: 'TEMPORARY_CHAT',
- RUN_CODE: 'RUN_CODE',
- WEB_SEARCH: 'WEB_SEARCH',
- FILE_CITATIONS: 'FILE_CITATIONS',
- },
- Permissions: {
- USE: 'USE',
- OPT_OUT: 'OPT_OUT',
- },
- SystemRoles: {
- USER: 'USER',
- ADMIN: 'ADMIN',
- },
-}));
-
-jest.mock('~/server/services/Files/images', () => ({
- convertImage: jest.fn(),
- resizeAndConvert: jest.fn(),
- resizeImageBuffer: jest.fn(),
-}));
-
-jest.mock('~/server/controllers/assistants/v2', () => ({
- addResourceFileId: jest.fn(),
- deleteResourceFileId: jest.fn(),
-}));
-
-jest.mock('~/models/Agent', () => ({
- addAgentResourceFile: jest.fn(),
- removeAgentResourceFiles: jest.fn(),
-}));
-
-jest.mock('~/server/controllers/assistants/helpers', () => ({
- getOpenAIClient: jest.fn(),
-}));
-
-jest.mock('~/server/services/Tools/credentials', () => ({
- loadAuthValues: jest.fn(),
-}));
-
-jest.mock('~/server/services/Config', () => ({
- checkCapability: jest.fn(),
-}));
-
-jest.mock('~/server/utils/queue', () => ({
- LB_QueueAsyncCall: jest.fn(),
-}));
-
-jest.mock('./strategies', () => ({
- getStrategyFunctions: jest.fn(),
-}));
-
-jest.mock('~/server/utils', () => ({
- determineFileType: jest.fn(),
-}));
-
-jest.mock('@librechat/api', () => ({
- parseText: jest.fn(),
- parseTextNative: jest.fn(),
-}));
-
-// Import the actual processFiles function after all mocks are set up
-const { processFiles } = require('./process');
-const { updateFileUsage } = require('~/models/File');
-
-describe('processFiles', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- describe('null filtering functionality', () => {
- it('should filter out null results from updateFileUsage when files do not exist', async () => {
- const mockFiles = [
- { file_id: 'existing-file-1' },
- { file_id: 'non-existent-file' },
- { file_id: 'existing-file-2' },
- ];
-
- // Mock updateFileUsage to return null for non-existent files
- updateFileUsage.mockImplementation(({ file_id }) => {
- if (file_id === 'non-existent-file') {
- return Promise.resolve(null); // Simulate file not found in the database
- }
- return Promise.resolve({ file_id, usage: 1 });
- });
-
- const result = await processFiles(mockFiles);
-
- expect(updateFileUsage).toHaveBeenCalledTimes(3);
- expect(result).toEqual([
- { file_id: 'existing-file-1', usage: 1 },
- { file_id: 'existing-file-2', usage: 1 },
- ]);
-
- // Critical test - ensure no null values in result
- expect(result).not.toContain(null);
- expect(result).not.toContain(undefined);
- expect(result.length).toBe(2); // Only valid files should be returned
- });
-
- it('should return empty array when all updateFileUsage calls return null', async () => {
- const mockFiles = [{ file_id: 'non-existent-1' }, { file_id: 'non-existent-2' }];
-
- // All updateFileUsage calls return null
- updateFileUsage.mockResolvedValue(null);
-
- const result = await processFiles(mockFiles);
-
- expect(updateFileUsage).toHaveBeenCalledTimes(2);
- expect(result).toEqual([]);
- expect(result).not.toContain(null);
- expect(result.length).toBe(0);
- });
-
- it('should work correctly when all files exist', async () => {
- const mockFiles = [{ file_id: 'file-1' }, { file_id: 'file-2' }];
-
- updateFileUsage.mockImplementation(({ file_id }) => {
- return Promise.resolve({ file_id, usage: 1 });
- });
-
- const result = await processFiles(mockFiles);
-
- expect(result).toEqual([
- { file_id: 'file-1', usage: 1 },
- { file_id: 'file-2', usage: 1 },
- ]);
- expect(result).not.toContain(null);
- expect(result.length).toBe(2);
- });
-
- it('should handle fileIds parameter and filter nulls correctly', async () => {
- const mockFiles = [{ file_id: 'file-1' }];
- const mockFileIds = ['file-2', 'non-existent-file'];
-
- updateFileUsage.mockImplementation(({ file_id }) => {
- if (file_id === 'non-existent-file') {
- return Promise.resolve(null);
- }
- return Promise.resolve({ file_id, usage: 1 });
- });
-
- const result = await processFiles(mockFiles, mockFileIds);
-
- expect(result).toEqual([
- { file_id: 'file-1', usage: 1 },
- { file_id: 'file-2', usage: 1 },
- ]);
- expect(result).not.toContain(null);
- expect(result).not.toContain(undefined);
- expect(result.length).toBe(2);
- });
-
- it('should handle duplicate file_ids correctly', async () => {
- const mockFiles = [
- { file_id: 'duplicate-file' },
- { file_id: 'duplicate-file' }, // Duplicate should be ignored
- { file_id: 'unique-file' },
- ];
-
- updateFileUsage.mockImplementation(({ file_id }) => {
- return Promise.resolve({ file_id, usage: 1 });
- });
-
- const result = await processFiles(mockFiles);
-
- // Should only call updateFileUsage twice (duplicate ignored)
- expect(updateFileUsage).toHaveBeenCalledTimes(2);
- expect(result).toEqual([
- { file_id: 'duplicate-file', usage: 1 },
- { file_id: 'unique-file', usage: 1 },
- ]);
- expect(result.length).toBe(2);
- });
- });
-
- describe('edge cases', () => {
- it('should handle empty files array', async () => {
- const result = await processFiles([]);
- expect(result).toEqual([]);
- expect(updateFileUsage).not.toHaveBeenCalled();
- });
-
- it('should handle mixed null and undefined returns from updateFileUsage', async () => {
- const mockFiles = [{ file_id: 'file-1' }, { file_id: 'file-2' }, { file_id: 'file-3' }];
-
- updateFileUsage.mockImplementation(({ file_id }) => {
- if (file_id === 'file-1') return Promise.resolve(null);
- if (file_id === 'file-2') return Promise.resolve(undefined);
- return Promise.resolve({ file_id, usage: 1 });
- });
-
- const result = await processFiles(mockFiles);
-
- expect(result).toEqual([{ file_id: 'file-3', usage: 1 }]);
- expect(result).not.toContain(null);
- expect(result).not.toContain(undefined);
- expect(result.length).toBe(1);
- });
- });
-});
diff --git a/api/server/services/Files/strategies.js b/api/server/services/Files/strategies.js
index 2ad526194b..25341b5715 100644
--- a/api/server/services/Files/strategies.js
+++ b/api/server/services/Files/strategies.js
@@ -1,5 +1,6 @@
const { FileSources } = require('librechat-data-provider');
const {
+ parseDocument,
uploadMistralOCR,
uploadAzureMistralOCR,
uploadGoogleVertexMistralOCR,
@@ -246,6 +247,26 @@ const vertexMistralOCRStrategy = () => ({
handleFileUpload: uploadGoogleVertexMistralOCR,
});
+const documentParserStrategy = () => ({
+ /** @type {typeof saveFileFromURL | null} */
+ saveURL: null,
+ /** @type {typeof getLocalFileURL | null} */
+ getFileURL: null,
+ /** @type {typeof saveLocalBuffer | null} */
+ saveBuffer: null,
+ /** @type {typeof processLocalAvatar | null} */
+ processAvatar: null,
+ /** @type {typeof uploadLocalImage | null} */
+ handleImageUpload: null,
+ /** @type {typeof prepareImagesLocal | null} */
+ prepareImagePayload: null,
+ /** @type {typeof deleteLocalFile | null} */
+ deleteFile: null,
+ /** @type {typeof getLocalFileStream | null} */
+ getDownloadStream: null,
+ handleFileUpload: parseDocument,
+});
+
// Strategy Selector
const getStrategyFunctions = (fileSource) => {
if (fileSource === FileSources.firebase) {
@@ -270,6 +291,8 @@ const getStrategyFunctions = (fileSource) => {
return azureMistralOCRStrategy();
} else if (fileSource === FileSources.vertexai_mistral_ocr) {
return vertexMistralOCRStrategy();
+ } else if (fileSource === FileSources.document_parser) {
+ return documentParserStrategy();
} else if (fileSource === FileSources.text) {
return localStrategy(); // Text files use local strategy
} else {
diff --git a/api/server/services/GraphApiService.spec.js b/api/server/services/GraphApiService.spec.js
index fa11190cc3..0a625e77e1 100644
--- a/api/server/services/GraphApiService.spec.js
+++ b/api/server/services/GraphApiService.spec.js
@@ -18,9 +18,6 @@ jest.mock('~/config', () => ({
defaults: {},
})),
}));
-jest.mock('~/utils', () => ({
- logAxiosError: jest.fn(),
-}));
jest.mock('~/server/services/Config', () => ({}));
jest.mock('~/server/services/Files/strategies', () => ({
diff --git a/api/server/services/GraphTokenService.js b/api/server/services/GraphTokenService.js
index d5cd6a94f2..843adbe5a2 100644
--- a/api/server/services/GraphTokenService.js
+++ b/api/server/services/GraphTokenService.js
@@ -7,7 +7,7 @@ const getLogStores = require('~/cache/getLogStores');
/**
* Get Microsoft Graph API token using existing token exchange mechanism
* @param {Object} user - User object with OpenID information
- * @param {string} accessToken - Current access token from Authorization header
+ * @param {string} accessToken - Federated access token used as OBO assertion
* @param {string} scopes - Graph API scopes for the token
* @param {boolean} fromCache - Whether to try getting token from cache first
* @returns {Promise} Graph API token response with access_token and expires_in
diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js
index e91e5e7904..c66eb0b6ef 100644
--- a/api/server/services/MCP.js
+++ b/api/server/services/MCP.js
@@ -1,4 +1,3 @@
-const { z } = require('zod');
const { tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const {
@@ -10,8 +9,11 @@ const {
const {
sendEvent,
MCPOAuthHandler,
+ isMCPDomainAllowed,
normalizeServerName,
- convertWithResolvedRefs,
+ normalizeJsonSchema,
+ GenerationJobManager,
+ resolveJsonSchemaRefs,
} = require('@librechat/api');
const {
Time,
@@ -20,25 +22,90 @@ const {
ContentTypes,
isAssistantsEndpoint,
} = require('librechat-data-provider');
-const { getMCPManager, getFlowStateManager, getOAuthReconnectionManager } = require('~/config');
+const {
+ getOAuthReconnectionManager,
+ getMCPServersRegistry,
+ getFlowStateManager,
+ getMCPManager,
+} = require('~/config');
const { findToken, createToken, updateToken } = require('~/models');
+const { getGraphApiToken } = require('./GraphTokenService');
const { reinitMCPServer } = require('./Tools/mcp');
const { getAppConfig } = require('./Config');
const { getLogStores } = require('~/cache');
-const { mcpServersRegistry } = require('@librechat/api');
+
+const MAX_CACHE_SIZE = 1000;
+const lastReconnectAttempts = new Map();
+const RECONNECT_THROTTLE_MS = 10_000;
+
+const missingToolCache = new Map();
+const MISSING_TOOL_TTL_MS = 10_000;
+
+function evictStale(map, ttl) {
+ if (map.size <= MAX_CACHE_SIZE) {
+ return;
+ }
+ const now = Date.now();
+ for (const [key, timestamp] of map) {
+ if (now - timestamp >= ttl) {
+ map.delete(key);
+ }
+ if (map.size <= MAX_CACHE_SIZE) {
+ return;
+ }
+ }
+}
+
+const unavailableMsg =
+ "This tool's MCP server is temporarily unavailable. Please try again shortly.";
+
+/**
+ * @param {string} toolName
+ * @param {string} serverName
+ */
+function createUnavailableToolStub(toolName, serverName) {
+ const normalizedToolKey = `${toolName}${Constants.mcp_delimiter}${normalizeServerName(serverName)}`;
+ const _call = async () => [unavailableMsg, null];
+ const toolInstance = tool(_call, {
+ schema: {
+ type: 'object',
+ properties: {
+ input: { type: 'string', description: 'Input for the tool' },
+ },
+ required: [],
+ },
+ name: normalizedToolKey,
+ description: unavailableMsg,
+ responseFormat: AgentConstants.CONTENT_AND_ARTIFACT,
+ });
+ toolInstance.mcp = true;
+ toolInstance.mcpRawServerName = serverName;
+ return toolInstance;
+}
+
+function isEmptyObjectSchema(jsonSchema) {
+ return (
+ jsonSchema != null &&
+ typeof jsonSchema === 'object' &&
+ jsonSchema.type === 'object' &&
+ (jsonSchema.properties == null || Object.keys(jsonSchema.properties).length === 0) &&
+ !jsonSchema.additionalProperties
+ );
+}
/**
* @param {object} params
* @param {ServerResponse} params.res - The Express response object for sending events.
* @param {string} params.stepId - The ID of the step in the flow.
* @param {ToolCallChunk} params.toolCall - The tool call object containing tool information.
+ * @param {string | null} [params.streamId] - The stream ID for resumable mode.
*/
-function createRunStepDeltaEmitter({ res, stepId, toolCall }) {
+function createRunStepDeltaEmitter({ res, stepId, toolCall, streamId = null }) {
/**
* @param {string} authURL - The URL to redirect the user for OAuth authentication.
- * @returns {void}
+ * @returns {Promise}
*/
- return function (authURL) {
+ return async function (authURL) {
/** @type {{ id: string; delta: AgentToolCallDelta }} */
const data = {
id: stepId,
@@ -49,7 +116,12 @@ function createRunStepDeltaEmitter({ res, stepId, toolCall }) {
expires_at: Date.now() + Time.TWO_MINUTES,
},
};
- sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data });
+ const eventData = { event: GraphEvents.ON_RUN_STEP_DELTA, data };
+ if (streamId) {
+ await GenerationJobManager.emitChunk(streamId, eventData);
+ } else {
+ sendEvent(res, eventData);
+ }
};
}
@@ -60,9 +132,11 @@ function createRunStepDeltaEmitter({ res, stepId, toolCall }) {
* @param {string} params.stepId - The ID of the step in the flow.
* @param {ToolCallChunk} params.toolCall - The tool call object containing tool information.
* @param {number} [params.index]
+ * @param {string | null} [params.streamId] - The stream ID for resumable mode.
+ * @returns {() => Promise}
*/
-function createRunStepEmitter({ res, runId, stepId, toolCall, index }) {
- return function () {
+function createRunStepEmitter({ res, runId, stepId, toolCall, index, streamId = null }) {
+ return async function () {
/** @type {import('@librechat/agents').RunStep} */
const data = {
runId: runId ?? Constants.USE_PRELIM_RESPONSE_MESSAGE_ID,
@@ -74,7 +148,12 @@ function createRunStepEmitter({ res, runId, stepId, toolCall, index }) {
tool_calls: [toolCall],
},
};
- sendEvent(res, { event: GraphEvents.ON_RUN_STEP, data });
+ const eventData = { event: GraphEvents.ON_RUN_STEP, data };
+ if (streamId) {
+ await GenerationJobManager.emitChunk(streamId, eventData);
+ } else {
+ sendEvent(res, eventData);
+ }
};
}
@@ -105,10 +184,9 @@ function createOAuthStart({ flowId, flowManager, callback }) {
* @param {ServerResponse} params.res - The Express response object for sending events.
* @param {string} params.stepId - The ID of the step in the flow.
* @param {ToolCallChunk} params.toolCall - The tool call object containing tool information.
- * @param {string} params.loginFlowId - The ID of the login flow.
- * @param {FlowStateManager} params.flowManager - The flow manager instance.
+ * @param {string | null} [params.streamId] - The stream ID for resumable mode.
*/
-function createOAuthEnd({ res, stepId, toolCall }) {
+function createOAuthEnd({ res, stepId, toolCall, streamId = null }) {
return async function () {
/** @type {{ id: string; delta: AgentToolCallDelta }} */
const data = {
@@ -118,7 +196,12 @@ function createOAuthEnd({ res, stepId, toolCall }) {
tool_calls: [{ ...toolCall }],
},
};
- sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data });
+ const eventData = { event: GraphEvents.ON_RUN_STEP_DELTA, data };
+ if (streamId) {
+ await GenerationJobManager.emitChunk(streamId, eventData);
+ } else {
+ sendEvent(res, eventData);
+ }
logger.debug('Sent OAuth login success to client');
};
}
@@ -134,7 +217,9 @@ function createAbortHandler({ userId, serverName, toolName, flowManager }) {
return function () {
logger.info(`[MCP][User: ${userId}][${serverName}][${toolName}] Tool call aborted`);
const flowId = MCPOAuthHandler.generateFlowId(userId, serverName);
+ // Clean up both mcp_oauth and mcp_get_tokens flows
flowManager.failFlow(flowId, 'mcp_oauth', new Error('Tool call aborted'));
+ flowManager.failFlow(flowId, 'mcp_get_tokens', new Error('Tool call aborted'));
};
}
@@ -159,10 +244,33 @@ function createOAuthCallback({ runStepEmitter, runStepDeltaEmitter }) {
* @param {AbortSignal} params.signal
* @param {string} params.model
* @param {number} [params.index]
+ * @param {string | null} [params.streamId] - The stream ID for resumable mode.
* @param {Record>} [params.userMCPAuthMap]
* @returns { Promise unknown}>> } An object with `_call` method to execute the tool input.
*/
-async function reconnectServer({ res, user, index, signal, serverName, userMCPAuthMap }) {
+async function reconnectServer({
+ res,
+ user,
+ index,
+ signal,
+ serverName,
+ userMCPAuthMap,
+ streamId = null,
+}) {
+ logger.debug(
+ `[MCP][reconnectServer] serverName: ${serverName}, user: ${user?.id}, hasUserMCPAuthMap: ${!!userMCPAuthMap}`,
+ );
+
+ const throttleKey = `${user.id}:${serverName}`;
+ const now = Date.now();
+ const lastAttempt = lastReconnectAttempts.get(throttleKey) ?? 0;
+ if (now - lastAttempt < RECONNECT_THROTTLE_MS) {
+ logger.debug(`[MCP][reconnectServer] Throttled reconnect for ${serverName}`);
+ return null;
+ }
+ lastReconnectAttempts.set(throttleKey, now);
+ evictStale(lastReconnectAttempts, RECONNECT_THROTTLE_MS);
+
const runId = Constants.USE_PRELIM_RESPONSE_MESSAGE_ID;
const flowId = `${user.id}:${serverName}:${Date.now()}`;
const flowManager = getFlowStateManager(getLogStores(CacheKeys.FLOWS));
@@ -173,36 +281,60 @@ async function reconnectServer({ res, user, index, signal, serverName, userMCPAu
type: 'tool_call_chunk',
};
- const runStepEmitter = createRunStepEmitter({
- res,
- index,
- runId,
- stepId,
- toolCall,
- });
- const runStepDeltaEmitter = createRunStepDeltaEmitter({
- res,
- stepId,
- toolCall,
- });
- const callback = createOAuthCallback({ runStepEmitter, runStepDeltaEmitter });
- const oauthStart = createOAuthStart({
- res,
- flowId,
- callback,
- flowManager,
- });
- return await reinitMCPServer({
- user,
- signal,
- serverName,
- oauthStart,
- flowManager,
- userMCPAuthMap,
- forceNew: true,
- returnOnOAuth: false,
- connectionTimeout: Time.TWO_MINUTES,
- });
+ // Set up abort handler to clean up OAuth flows if request is aborted
+ const oauthFlowId = MCPOAuthHandler.generateFlowId(user.id, serverName);
+ const abortHandler = () => {
+ logger.info(
+ `[MCP][User: ${user.id}][${serverName}] Tool loading aborted, cleaning up OAuth flows`,
+ );
+ // Clean up both mcp_oauth and mcp_get_tokens flows
+ flowManager.failFlow(oauthFlowId, 'mcp_oauth', new Error('Tool loading aborted'));
+ flowManager.failFlow(oauthFlowId, 'mcp_get_tokens', new Error('Tool loading aborted'));
+ };
+
+ if (signal) {
+ signal.addEventListener('abort', abortHandler, { once: true });
+ }
+
+ try {
+ const runStepEmitter = createRunStepEmitter({
+ res,
+ index,
+ runId,
+ stepId,
+ toolCall,
+ streamId,
+ });
+ const runStepDeltaEmitter = createRunStepDeltaEmitter({
+ res,
+ stepId,
+ toolCall,
+ streamId,
+ });
+ const callback = createOAuthCallback({ runStepEmitter, runStepDeltaEmitter });
+ const oauthStart = createOAuthStart({
+ res,
+ flowId,
+ callback,
+ flowManager,
+ });
+ return await reinitMCPServer({
+ user,
+ signal,
+ serverName,
+ oauthStart,
+ flowManager,
+ userMCPAuthMap,
+ forceNew: true,
+ returnOnOAuth: false,
+ connectionTimeout: Time.THIRTY_SECONDS,
+ });
+ } finally {
+ // Clean up abort handler to prevent memory leaks
+ if (signal) {
+ signal.removeEventListener('abort', abortHandler);
+ }
+ }
}
/**
@@ -219,14 +351,52 @@ async function reconnectServer({ res, user, index, signal, serverName, userMCPAu
* @param {Providers | EModelEndpoint} params.provider - The provider for the tool.
* @param {number} [params.index]
* @param {AbortSignal} [params.signal]
+ * @param {string | null} [params.streamId] - The stream ID for resumable mode.
+ * @param {import('@librechat/api').ParsedServerConfig} [params.config]
* @param {Record>} [params.userMCPAuthMap]
* @returns { Promise unknown}>> } An object with `_call` method to execute the tool input.
*/
-async function createMCPTools({ res, user, index, signal, serverName, provider, userMCPAuthMap }) {
- const result = await reconnectServer({ res, user, index, signal, serverName, userMCPAuthMap });
+async function createMCPTools({
+ res,
+ user,
+ index,
+ signal,
+ config,
+ provider,
+ serverName,
+ userMCPAuthMap,
+ streamId = null,
+}) {
+ // Early domain validation before reconnecting server (avoid wasted work on disallowed domains)
+ // Use getAppConfig() to support per-user/role domain restrictions
+ const serverConfig =
+ config ?? (await getMCPServersRegistry().getServerConfig(serverName, user?.id));
+ if (serverConfig?.url) {
+ const appConfig = await getAppConfig({ role: user?.role });
+ const allowedDomains = appConfig?.mcpSettings?.allowedDomains;
+ const isDomainAllowed = await isMCPDomainAllowed(serverConfig, allowedDomains);
+ if (!isDomainAllowed) {
+ logger.warn(`[MCP][${serverName}] Domain not allowed, skipping all tools`);
+ return [];
+ }
+ }
+
+ const result = await reconnectServer({
+ res,
+ user,
+ index,
+ signal,
+ serverName,
+ userMCPAuthMap,
+ streamId,
+ });
+ if (result === null) {
+ logger.debug(`[MCP][${serverName}] Reconnect throttled, skipping tool creation.`);
+ return [];
+ }
if (!result || !result.tools) {
logger.warn(`[MCP][${serverName}] Failed to reinitialize MCP server.`);
- return;
+ return [];
}
const serverTools = [];
@@ -236,8 +406,10 @@ async function createMCPTools({ res, user, index, signal, serverName, provider,
user,
provider,
userMCPAuthMap,
+ streamId,
availableTools: result.availableTools,
toolKey: `${tool.name}${Constants.mcp_delimiter}${serverName}`,
+ config: serverConfig,
});
if (toolInstance) {
serverTools.push(toolInstance);
@@ -256,9 +428,11 @@ async function createMCPTools({ res, user, index, signal, serverName, provider,
* @param {string} params.model - The model for the tool.
* @param {number} [params.index]
* @param {AbortSignal} [params.signal]
+ * @param {string | null} [params.streamId] - The stream ID for resumable mode.
* @param {Providers | EModelEndpoint} params.provider - The provider for the tool.
* @param {LCAvailableTools} [params.availableTools]
* @param {Record>} [params.userMCPAuthMap]
+ * @param {import('@librechat/api').ParsedServerConfig} [params.config]
* @returns { Promise unknown}> } An object with `_call` method to execute the tool input.
*/
async function createMCPTool({
@@ -270,12 +444,36 @@ async function createMCPTool({
provider,
userMCPAuthMap,
availableTools,
+ config,
+ streamId = null,
}) {
const [toolName, serverName] = toolKey.split(Constants.mcp_delimiter);
+ // Runtime domain validation: check if the server's domain is still allowed
+ // Use getAppConfig() to support per-user/role domain restrictions
+ const serverConfig =
+ config ?? (await getMCPServersRegistry().getServerConfig(serverName, user?.id));
+ if (serverConfig?.url) {
+ const appConfig = await getAppConfig({ role: user?.role });
+ const allowedDomains = appConfig?.mcpSettings?.allowedDomains;
+ const isDomainAllowed = await isMCPDomainAllowed(serverConfig, allowedDomains);
+ if (!isDomainAllowed) {
+ logger.warn(`[MCP][${serverName}] Domain no longer allowed, skipping tool: ${toolName}`);
+ return undefined;
+ }
+ }
+
/** @type {LCTool | undefined} */
let toolDefinition = availableTools?.[toolKey]?.function;
if (!toolDefinition) {
+ const cachedAt = missingToolCache.get(toolKey);
+ if (cachedAt && Date.now() - cachedAt < MISSING_TOOL_TTL_MS) {
+ logger.debug(
+ `[MCP][${serverName}][${toolName}] Tool in negative cache, returning unavailable stub.`,
+ );
+ return createUnavailableToolStub(toolName, serverName);
+ }
+
logger.warn(
`[MCP][${serverName}][${toolName}] Requested tool not found in available tools, re-initializing MCP server.`,
);
@@ -286,13 +484,21 @@ async function createMCPTool({
signal,
serverName,
userMCPAuthMap,
+ streamId,
});
toolDefinition = result?.availableTools?.[toolKey]?.function;
+
+ if (!toolDefinition) {
+ missingToolCache.set(toolKey, Date.now());
+ evictStale(missingToolCache, MISSING_TOOL_TTL_MS);
+ }
}
if (!toolDefinition) {
- logger.warn(`[MCP][${serverName}][${toolName}] Tool definition not found, cannot create tool.`);
- return;
+ logger.warn(
+ `[MCP][${serverName}][${toolName}] Tool definition not found, returning unavailable stub.`,
+ );
+ return createUnavailableToolStub(toolName, serverName);
}
return createToolInstance({
@@ -301,20 +507,32 @@ async function createMCPTool({
toolName,
serverName,
toolDefinition,
+ streamId,
});
}
-function createToolInstance({ res, toolName, serverName, toolDefinition, provider: _provider }) {
+function createToolInstance({
+ res,
+ toolName,
+ serverName,
+ toolDefinition,
+ provider: _provider,
+ streamId = null,
+}) {
/** @type {LCTool} */
const { description, parameters } = toolDefinition;
const isGoogle = _provider === Providers.VERTEXAI || _provider === Providers.GOOGLE;
- let schema = convertWithResolvedRefs(parameters, {
- allowEmptyObject: !isGoogle,
- transformOneOfAnyOf: true,
- });
- if (!schema) {
- schema = z.object({ input: z.string().optional() });
+ let schema = parameters ? normalizeJsonSchema(resolveJsonSchemaRefs(parameters)) : null;
+
+ if (!schema || (isGoogle && isEmptyObjectSchema(schema))) {
+ schema = {
+ type: 'object',
+ properties: {
+ input: { type: 'string', description: 'Input for the tool' },
+ },
+ required: [],
+ };
}
const normalizedToolKey = `${toolName}${Constants.mcp_delimiter}${normalizeServerName(serverName)}`;
@@ -340,6 +558,7 @@ function createToolInstance({ res, toolName, serverName, toolDefinition, provide
res,
stepId,
toolCall,
+ streamId,
});
const oauthStart = createOAuthStart({
flowId,
@@ -350,6 +569,7 @@ function createToolInstance({ res, toolName, serverName, toolDefinition, provide
res,
stepId,
toolCall,
+ streamId,
});
if (derivedSignal) {
@@ -379,6 +599,7 @@ function createToolInstance({ res, toolName, serverName, toolDefinition, provide
},
oauthStart,
oauthEnd,
+ graphTokenResolver: getGraphApiToken,
});
if (isAssistantsEndpoint(provider) && Array.isArray(result)) {
@@ -426,6 +647,7 @@ function createToolInstance({ res, toolName, serverName, toolDefinition, provide
});
toolInstance.mcp = true;
toolInstance.mcpRawServerName = serverName;
+ toolInstance.mcpJsonSchema = parameters;
return toolInstance;
}
@@ -435,8 +657,7 @@ function createToolInstance({ res, toolName, serverName, toolDefinition, provide
* @returns {Object} Object containing mcpConfig, appConnections, userConnections, and oauthServers
*/
async function getMCPSetupData(userId) {
- const config = await getAppConfig();
- const mcpConfig = config?.mcpConfig;
+ const mcpConfig = await getMCPServersRegistry().getAllServerConfigs(userId);
if (!mcpConfig) {
throw new Error('MCP config not found');
@@ -446,12 +667,15 @@ async function getMCPSetupData(userId) {
/** @type {Map} */
let appConnections = new Map();
try {
- appConnections = (await mcpManager.appConnections?.getAll()) || new Map();
+ // Use getLoaded() instead of getAll() to avoid forcing connection creation
+ // getAll() creates connections for all servers, which is problematic for servers
+ // that require user context (e.g., those with {{LIBRECHAT_USER_ID}} placeholders)
+ appConnections = (await mcpManager.appConnections?.getLoaded()) || new Map();
} catch (error) {
logger.error(`[MCP][User: ${userId}] Error getting app connections:`, error);
}
const userConnections = mcpManager.getUserConnections(userId) || new Map();
- const oauthServers = await mcpServersRegistry.getOAuthServers();
+ const oauthServers = await getMCPServersRegistry().getOAuthServers(userId);
return {
mcpConfig,
@@ -524,24 +748,26 @@ async function checkOAuthFlowStatus(userId, serverName) {
* Get connection status for a specific MCP server
* @param {string} userId - The user ID
* @param {string} serverName - The server name
- * @param {Map} appConnections - App-level connections
- * @param {Map} userConnections - User-level connections
+ * @param {import('@librechat/api').ParsedServerConfig} config - The server configuration
+ * @param {Map} appConnections - App-level connections
+ * @param {Map} userConnections - User-level connections
* @param {Set} oauthServers - Set of OAuth servers
* @returns {Object} Object containing requiresOAuth and connectionState
*/
async function getServerConnectionStatus(
userId,
serverName,
+ config,
appConnections,
userConnections,
oauthServers,
) {
- const getConnectionState = () =>
- appConnections.get(serverName)?.connectionState ??
- userConnections.get(serverName)?.connectionState ??
- 'disconnected';
+ const connection = appConnections.get(serverName) || userConnections.get(serverName);
+ const isStaleOrDoNotExist = connection ? connection?.isStale(config.updatedAt) : true;
- const baseConnectionState = getConnectionState();
+ const baseConnectionState = isStaleOrDoNotExist
+ ? 'disconnected'
+ : connection?.connectionState || 'disconnected';
let finalConnectionState = baseConnectionState;
// connection state overrides specific to OAuth servers
@@ -573,4 +799,5 @@ module.exports = {
getMCPSetupData,
checkOAuthFlowStatus,
getServerConnectionStatus,
+ createUnavailableToolStub,
};
diff --git a/api/server/services/MCP.spec.js b/api/server/services/MCP.spec.js
index 18857c4893..14a9ef90ed 100644
--- a/api/server/services/MCP.spec.js
+++ b/api/server/services/MCP.spec.js
@@ -1,14 +1,4 @@
-const { logger } = require('@librechat/data-schemas');
-const { MCPOAuthHandler } = require('@librechat/api');
-const { CacheKeys } = require('librechat-data-provider');
-const {
- createMCPTool,
- createMCPTools,
- getMCPSetupData,
- checkOAuthFlowStatus,
- getServerConnectionStatus,
-} = require('./MCP');
-
+// Mock all dependencies - define mocks before imports
// Mock all dependencies
jest.mock('@librechat/data-schemas', () => ({
logger: {
@@ -19,69 +9,57 @@ jest.mock('@librechat/data-schemas', () => ({
},
}));
-jest.mock('@langchain/core/tools', () => ({
- tool: jest.fn((fn, config) => {
- const toolInstance = { _call: fn, ...config };
- return toolInstance;
- }),
-}));
+// Create mock registry instance
+const mockRegistryInstance = {
+ getOAuthServers: jest.fn(() => Promise.resolve(new Set())),
+ getAllServerConfigs: jest.fn(() => Promise.resolve({})),
+ getServerConfig: jest.fn(() => Promise.resolve(null)),
+};
-jest.mock('@librechat/agents', () => ({
- Providers: {
- VERTEXAI: 'vertexai',
- GOOGLE: 'google',
- },
- StepTypes: {
- TOOL_CALLS: 'tool_calls',
- },
- GraphEvents: {
- ON_RUN_STEP_DELTA: 'on_run_step_delta',
- ON_RUN_STEP: 'on_run_step',
- },
- Constants: {
- CONTENT_AND_ARTIFACT: 'content_and_artifact',
- },
-}));
+// Create isMCPDomainAllowed mock that can be configured per-test
+const mockIsMCPDomainAllowed = jest.fn(() => Promise.resolve(true));
-jest.mock('@librechat/api', () => ({
- MCPOAuthHandler: {
- generateFlowId: jest.fn(),
- },
- sendEvent: jest.fn(),
- normalizeServerName: jest.fn((name) => name),
- convertWithResolvedRefs: jest.fn((params) => params),
- mcpServersRegistry: {
- getOAuthServers: jest.fn(() => Promise.resolve(new Set())),
- },
-}));
+const mockGetAppConfig = jest.fn(() => Promise.resolve({}));
-jest.mock('librechat-data-provider', () => ({
- CacheKeys: {
- FLOWS: 'flows',
- },
- Constants: {
- USE_PRELIM_RESPONSE_MESSAGE_ID: 'prelim_response_id',
- mcp_delimiter: '::',
- mcp_prefix: 'mcp_',
- },
- ContentTypes: {
- TEXT: 'text',
- },
- isAssistantsEndpoint: jest.fn(() => false),
- Time: {
- TWO_MINUTES: 120000,
- },
-}));
+jest.mock('@librechat/api', () => {
+ const actual = jest.requireActual('@librechat/api');
+ return {
+ ...actual,
+ sendEvent: jest.fn(),
+ get isMCPDomainAllowed() {
+ return mockIsMCPDomainAllowed;
+ },
+ GenerationJobManager: {
+ emitChunk: jest.fn(),
+ },
+ };
+});
+
+const { logger } = require('@librechat/data-schemas');
+const { MCPOAuthHandler } = require('@librechat/api');
+const { CacheKeys, Constants } = require('librechat-data-provider');
+const D = Constants.mcp_delimiter;
+const {
+ createMCPTool,
+ createMCPTools,
+ getMCPSetupData,
+ checkOAuthFlowStatus,
+ getServerConnectionStatus,
+ createUnavailableToolStub,
+} = require('./MCP');
jest.mock('./Config', () => ({
loadCustomConfig: jest.fn(),
- getAppConfig: jest.fn(),
+ get getAppConfig() {
+ return mockGetAppConfig;
+ },
}));
jest.mock('~/config', () => ({
getMCPManager: jest.fn(),
getFlowStateManager: jest.fn(),
getOAuthReconnectionManager: jest.fn(),
+ getMCPServersRegistry: jest.fn(() => mockRegistryInstance),
}));
jest.mock('~/cache', () => ({
@@ -98,66 +76,66 @@ jest.mock('./Tools/mcp', () => ({
reinitMCPServer: jest.fn(),
}));
+jest.mock('./GraphTokenService', () => ({
+ getGraphApiToken: jest.fn(),
+}));
+
describe('tests for the new helper functions used by the MCP connection status endpoints', () => {
let mockGetMCPManager;
let mockGetFlowStateManager;
let mockGetLogStores;
let mockGetOAuthReconnectionManager;
- let mockMcpServersRegistry;
beforeEach(() => {
jest.clearAllMocks();
+ jest.spyOn(MCPOAuthHandler, 'generateFlowId');
mockGetMCPManager = require('~/config').getMCPManager;
mockGetFlowStateManager = require('~/config').getFlowStateManager;
mockGetLogStores = require('~/cache').getLogStores;
mockGetOAuthReconnectionManager = require('~/config').getOAuthReconnectionManager;
- mockMcpServersRegistry = require('@librechat/api').mcpServersRegistry;
});
describe('getMCPSetupData', () => {
const mockUserId = 'user-123';
const mockConfig = {
- mcpServers: {
- server1: { type: 'stdio' },
- server2: { type: 'http' },
- },
+ server1: { type: 'stdio' },
+ server2: { type: 'http' },
};
- let mockGetAppConfig;
beforeEach(() => {
- mockGetAppConfig = require('./Config').getAppConfig;
mockGetMCPManager.mockReturnValue({
- appConnections: { getAll: jest.fn(() => new Map()) },
+ appConnections: { getLoaded: jest.fn(() => new Map()) },
getUserConnections: jest.fn(() => new Map()),
});
- mockMcpServersRegistry.getOAuthServers.mockResolvedValue(new Set());
+ mockRegistryInstance.getOAuthServers.mockResolvedValue(new Set());
+ mockRegistryInstance.getAllServerConfigs.mockResolvedValue(mockConfig);
});
it('should successfully return MCP setup data', async () => {
- mockGetAppConfig.mockResolvedValue({ mcpConfig: mockConfig.mcpServers });
+ mockRegistryInstance.getAllServerConfigs.mockResolvedValue(mockConfig);
const mockAppConnections = new Map([['server1', { status: 'connected' }]]);
const mockUserConnections = new Map([['server2', { status: 'disconnected' }]]);
const mockOAuthServers = new Set(['server2']);
const mockMCPManager = {
- appConnections: { getAll: jest.fn(() => mockAppConnections) },
+ appConnections: { getLoaded: jest.fn(() => Promise.resolve(mockAppConnections)) },
getUserConnections: jest.fn(() => mockUserConnections),
};
mockGetMCPManager.mockReturnValue(mockMCPManager);
- mockMcpServersRegistry.getOAuthServers.mockResolvedValue(mockOAuthServers);
+ mockRegistryInstance.getOAuthServers.mockResolvedValue(mockOAuthServers);
const result = await getMCPSetupData(mockUserId);
- expect(mockGetAppConfig).toHaveBeenCalled();
+ expect(mockRegistryInstance.getAllServerConfigs).toHaveBeenCalledWith(mockUserId);
expect(mockGetMCPManager).toHaveBeenCalledWith(mockUserId);
- expect(mockMCPManager.appConnections.getAll).toHaveBeenCalled();
+ expect(mockMCPManager.appConnections.getLoaded).toHaveBeenCalled();
expect(mockMCPManager.getUserConnections).toHaveBeenCalledWith(mockUserId);
- expect(mockMcpServersRegistry.getOAuthServers).toHaveBeenCalled();
+ expect(mockRegistryInstance.getOAuthServers).toHaveBeenCalledWith(mockUserId);
expect(result).toEqual({
- mcpConfig: mockConfig.mcpServers,
+ mcpConfig: mockConfig,
appConnections: mockAppConnections,
userConnections: mockUserConnections,
oauthServers: mockOAuthServers,
@@ -165,24 +143,24 @@ describe('tests for the new helper functions used by the MCP connection status e
});
it('should throw error when MCP config not found', async () => {
- mockGetAppConfig.mockResolvedValue({});
+ mockRegistryInstance.getAllServerConfigs.mockResolvedValue(null);
await expect(getMCPSetupData(mockUserId)).rejects.toThrow('MCP config not found');
});
it('should handle null values from MCP manager gracefully', async () => {
- mockGetAppConfig.mockResolvedValue({ mcpConfig: mockConfig.mcpServers });
+ mockRegistryInstance.getAllServerConfigs.mockResolvedValue(mockConfig);
const mockMCPManager = {
- appConnections: { getAll: jest.fn(() => null) },
+ appConnections: { getLoaded: jest.fn(() => Promise.resolve(null)) },
getUserConnections: jest.fn(() => null),
};
mockGetMCPManager.mockReturnValue(mockMCPManager);
- mockMcpServersRegistry.getOAuthServers.mockResolvedValue(new Set());
+ mockRegistryInstance.getOAuthServers.mockResolvedValue(new Set());
const result = await getMCPSetupData(mockUserId);
expect(result).toEqual({
- mcpConfig: mockConfig.mcpServers,
+ mcpConfig: mockConfig,
appConnections: new Map(),
userConnections: new Map(),
oauthServers: new Set(),
@@ -329,15 +307,25 @@ describe('tests for the new helper functions used by the MCP connection status e
describe('getServerConnectionStatus', () => {
const mockUserId = 'user-123';
const mockServerName = 'test-server';
+ const mockConfig = { updatedAt: Date.now() };
it('should return app connection state when available', async () => {
- const appConnections = new Map([[mockServerName, { connectionState: 'connected' }]]);
+ const appConnections = new Map([
+ [
+ mockServerName,
+ {
+ connectionState: 'connected',
+ isStale: jest.fn(() => false),
+ },
+ ],
+ ]);
const userConnections = new Map();
const oauthServers = new Set();
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
+ mockConfig,
appConnections,
userConnections,
oauthServers,
@@ -351,12 +339,21 @@ describe('tests for the new helper functions used by the MCP connection status e
it('should fallback to user connection state when app connection not available', async () => {
const appConnections = new Map();
- const userConnections = new Map([[mockServerName, { connectionState: 'connecting' }]]);
+ const userConnections = new Map([
+ [
+ mockServerName,
+ {
+ connectionState: 'connecting',
+ isStale: jest.fn(() => false),
+ },
+ ],
+ ]);
const oauthServers = new Set();
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
+ mockConfig,
appConnections,
userConnections,
oauthServers,
@@ -376,6 +373,7 @@ describe('tests for the new helper functions used by the MCP connection status e
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
+ mockConfig,
appConnections,
userConnections,
oauthServers,
@@ -388,13 +386,30 @@ describe('tests for the new helper functions used by the MCP connection status e
});
it('should prioritize app connection over user connection', async () => {
- const appConnections = new Map([[mockServerName, { connectionState: 'connected' }]]);
- const userConnections = new Map([[mockServerName, { connectionState: 'disconnected' }]]);
+ const appConnections = new Map([
+ [
+ mockServerName,
+ {
+ connectionState: 'connected',
+ isStale: jest.fn(() => false),
+ },
+ ],
+ ]);
+ const userConnections = new Map([
+ [
+ mockServerName,
+ {
+ connectionState: 'disconnected',
+ isStale: jest.fn(() => false),
+ },
+ ],
+ ]);
const oauthServers = new Set();
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
+ mockConfig,
appConnections,
userConnections,
oauthServers,
@@ -420,6 +435,7 @@ describe('tests for the new helper functions used by the MCP connection status e
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
+ mockConfig,
appConnections,
userConnections,
oauthServers,
@@ -454,6 +470,7 @@ describe('tests for the new helper functions used by the MCP connection status e
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
+ mockConfig,
appConnections,
userConnections,
oauthServers,
@@ -491,6 +508,7 @@ describe('tests for the new helper functions used by the MCP connection status e
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
+ mockConfig,
appConnections,
userConnections,
oauthServers,
@@ -524,6 +542,7 @@ describe('tests for the new helper functions used by the MCP connection status e
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
+ mockConfig,
appConnections,
userConnections,
oauthServers,
@@ -549,6 +568,7 @@ describe('tests for the new helper functions used by the MCP connection status e
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
+ mockConfig,
appConnections,
userConnections,
oauthServers,
@@ -571,13 +591,22 @@ describe('tests for the new helper functions used by the MCP connection status e
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
mockGetLogStores.mockReturnValue({});
- const appConnections = new Map([[mockServerName, { connectionState: 'connected' }]]);
+ const appConnections = new Map([
+ [
+ mockServerName,
+ {
+ connectionState: 'connected',
+ isStale: jest.fn(() => false),
+ },
+ ],
+ ]);
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
+ mockConfig,
appConnections,
userConnections,
oauthServers,
@@ -606,6 +635,7 @@ describe('tests for the new helper functions used by the MCP connection status e
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
+ mockConfig,
appConnections,
userConnections,
oauthServers,
@@ -639,6 +669,18 @@ describe('User parameter passing tests', () => {
createFlowWithHandler: jest.fn(),
failFlow: jest.fn(),
});
+
+ // Reset domain validation mock to default (allow all)
+ mockIsMCPDomainAllowed.mockReset();
+ mockIsMCPDomainAllowed.mockResolvedValue(true);
+
+ // Reset registry mocks
+ mockRegistryInstance.getServerConfig.mockReset();
+ mockRegistryInstance.getServerConfig.mockResolvedValue(null);
+
+ // Reset getAppConfig mock to default (no restrictions)
+ mockGetAppConfig.mockReset();
+ mockGetAppConfig.mockResolvedValue({});
});
describe('createMCPTools', () => {
@@ -650,7 +692,7 @@ describe('User parameter passing tests', () => {
mockReinitMCPServer.mockResolvedValue({
tools: [{ name: 'test-tool' }],
availableTools: {
- 'test-tool::test-server': {
+ [`test-tool${D}test-server`]: {
function: {
description: 'Test tool',
parameters: { type: 'object', properties: {} },
@@ -710,7 +752,7 @@ describe('User parameter passing tests', () => {
mockReinitMCPServer.mockResolvedValue({
availableTools: {
- 'test-tool::test-server': {
+ [`test-tool${D}test-server`]: {
function: {
description: 'Test tool',
parameters: { type: 'object', properties: {} },
@@ -723,7 +765,7 @@ describe('User parameter passing tests', () => {
await createMCPTool({
res: mockRes,
user: mockUser,
- toolKey: 'test-tool::test-server',
+ toolKey: `test-tool${D}test-server`,
provider: 'openai',
signal: mockSignal,
userMCPAuthMap: {},
@@ -745,7 +787,7 @@ describe('User parameter passing tests', () => {
const mockRes = { write: jest.fn(), flush: jest.fn() };
const availableTools = {
- 'test-tool::test-server': {
+ [`test-tool${D}test-server`]: {
function: {
description: 'Cached tool',
parameters: { type: 'object', properties: {} },
@@ -756,7 +798,7 @@ describe('User parameter passing tests', () => {
await createMCPTool({
res: mockRes,
user: mockUser,
- toolKey: 'test-tool::test-server',
+ toolKey: `test-tool${D}test-server`,
provider: 'openai',
userMCPAuthMap: {},
availableTools: availableTools,
@@ -779,8 +821,8 @@ describe('User parameter passing tests', () => {
return Promise.resolve({
tools: [{ name: 'tool1' }, { name: 'tool2' }],
availableTools: {
- 'tool1::server1': { function: { description: 'Tool 1', parameters: {} } },
- 'tool2::server1': { function: { description: 'Tool 2', parameters: {} } },
+ [`tool1${D}server1`]: { function: { description: 'Tool 1', parameters: {} } },
+ [`tool2${D}server1`]: { function: { description: 'Tool 2', parameters: {} } },
},
});
});
@@ -811,7 +853,7 @@ describe('User parameter passing tests', () => {
reinitCalls.push(params);
return Promise.resolve({
availableTools: {
- 'my-tool::my-server': {
+ [`my-tool${D}my-server`]: {
function: { description: 'My Tool', parameters: {} },
},
},
@@ -821,7 +863,7 @@ describe('User parameter passing tests', () => {
await createMCPTool({
res: mockRes,
user: mockUser,
- toolKey: 'my-tool::my-server',
+ toolKey: `my-tool${D}my-server`,
provider: 'google',
userMCPAuthMap: {},
availableTools: undefined, // Force reinit
@@ -834,6 +876,411 @@ describe('User parameter passing tests', () => {
});
});
+ describe('Runtime domain validation', () => {
+ it('should skip tool creation when domain is not allowed', async () => {
+ const mockUser = { id: 'domain-test-user', role: 'user' };
+ const mockRes = { write: jest.fn(), flush: jest.fn() };
+
+ // Mock server config with URL (remote server)
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ url: 'https://disallowed-domain.com/sse',
+ });
+
+ // Mock getAppConfig to return domain restrictions
+ mockGetAppConfig.mockResolvedValue({
+ mcpSettings: { allowedDomains: ['allowed-domain.com'] },
+ });
+
+ // Mock domain validation to return false (domain not allowed)
+ mockIsMCPDomainAllowed.mockResolvedValueOnce(false);
+
+ const result = await createMCPTool({
+ res: mockRes,
+ user: mockUser,
+ toolKey: `test-tool${D}test-server`,
+ provider: 'openai',
+ userMCPAuthMap: {},
+ availableTools: {
+ [`test-tool${D}test-server`]: {
+ function: {
+ description: 'Test tool',
+ parameters: { type: 'object', properties: {} },
+ },
+ },
+ },
+ });
+
+ // Should return undefined for disallowed domain
+ expect(result).toBeUndefined();
+
+ // Should not call reinitMCPServer since domain check failed
+ expect(mockReinitMCPServer).not.toHaveBeenCalled();
+
+ // Verify getAppConfig was called with user role
+ expect(mockGetAppConfig).toHaveBeenCalledWith({ role: 'user' });
+
+ // Verify domain validation was called with correct parameters
+ expect(mockIsMCPDomainAllowed).toHaveBeenCalledWith(
+ { url: 'https://disallowed-domain.com/sse' },
+ ['allowed-domain.com'],
+ );
+ });
+
+ it('should allow tool creation when domain is allowed', async () => {
+ const mockUser = { id: 'domain-test-user', role: 'admin' };
+ const mockRes = { write: jest.fn(), flush: jest.fn() };
+
+ // Mock server config with URL (remote server)
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ url: 'https://allowed-domain.com/sse',
+ });
+
+ // Mock getAppConfig to return domain restrictions
+ mockGetAppConfig.mockResolvedValue({
+ mcpSettings: { allowedDomains: ['allowed-domain.com'] },
+ });
+
+ // Mock domain validation to return true (domain allowed)
+ mockIsMCPDomainAllowed.mockResolvedValueOnce(true);
+
+ const availableTools = {
+ [`test-tool${D}test-server`]: {
+ function: {
+ description: 'Test tool',
+ parameters: { type: 'object', properties: {} },
+ },
+ },
+ };
+
+ const result = await createMCPTool({
+ res: mockRes,
+ user: mockUser,
+ toolKey: `test-tool${D}test-server`,
+ provider: 'openai',
+ userMCPAuthMap: {},
+ availableTools,
+ });
+
+ // Should create tool successfully
+ expect(result).toBeDefined();
+
+ // Verify getAppConfig was called with user role
+ expect(mockGetAppConfig).toHaveBeenCalledWith({ role: 'admin' });
+ });
+
+ it('should skip domain validation for stdio transports (no URL)', async () => {
+ const mockUser = { id: 'stdio-test-user' };
+ const mockRes = { write: jest.fn(), flush: jest.fn() };
+
+ // Mock server config without URL (stdio transport)
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ command: 'npx',
+ args: ['@modelcontextprotocol/server'],
+ });
+
+ // Mock getAppConfig (should not be called for stdio)
+ mockGetAppConfig.mockResolvedValue({
+ mcpSettings: { allowedDomains: ['restricted-domain.com'] },
+ });
+
+ const availableTools = {
+ [`test-tool${D}test-server`]: {
+ function: {
+ description: 'Test tool',
+ parameters: { type: 'object', properties: {} },
+ },
+ },
+ };
+
+ const result = await createMCPTool({
+ res: mockRes,
+ user: mockUser,
+ toolKey: `test-tool${D}test-server`,
+ provider: 'openai',
+ userMCPAuthMap: {},
+ availableTools,
+ });
+
+ // Should create tool successfully without domain check
+ expect(result).toBeDefined();
+
+ // Should not call getAppConfig or isMCPDomainAllowed for stdio transport (no URL)
+ expect(mockGetAppConfig).not.toHaveBeenCalled();
+ expect(mockIsMCPDomainAllowed).not.toHaveBeenCalled();
+ });
+
+ it('should return empty array from createMCPTools when domain is not allowed', async () => {
+ const mockUser = { id: 'domain-test-user', role: 'user' };
+ const mockRes = { write: jest.fn(), flush: jest.fn() };
+
+ // Mock server config with URL (remote server)
+ const serverConfig = { url: 'https://disallowed-domain.com/sse' };
+ mockRegistryInstance.getServerConfig.mockResolvedValue(serverConfig);
+
+ // Mock getAppConfig to return domain restrictions
+ mockGetAppConfig.mockResolvedValue({
+ mcpSettings: { allowedDomains: ['allowed-domain.com'] },
+ });
+
+ // Mock domain validation to return false (domain not allowed)
+ mockIsMCPDomainAllowed.mockResolvedValueOnce(false);
+
+ const result = await createMCPTools({
+ res: mockRes,
+ user: mockUser,
+ serverName: 'test-server',
+ provider: 'openai',
+ userMCPAuthMap: {},
+ config: serverConfig,
+ });
+
+ // Should return empty array for disallowed domain
+ expect(result).toEqual([]);
+
+ // Should not call reinitMCPServer since domain check failed early
+ expect(mockReinitMCPServer).not.toHaveBeenCalled();
+
+ // Verify getAppConfig was called with user role
+ expect(mockGetAppConfig).toHaveBeenCalledWith({ role: 'user' });
+ });
+
+ it('should use user role when fetching domain restrictions', async () => {
+ const adminUser = { id: 'admin-user', role: 'admin' };
+ const regularUser = { id: 'regular-user', role: 'user' };
+ const mockRes = { write: jest.fn(), flush: jest.fn() };
+
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ url: 'https://some-domain.com/sse',
+ });
+
+ // Mock different responses based on role
+ mockGetAppConfig
+ .mockResolvedValueOnce({ mcpSettings: { allowedDomains: ['admin-allowed.com'] } })
+ .mockResolvedValueOnce({ mcpSettings: { allowedDomains: ['user-allowed.com'] } });
+
+ mockIsMCPDomainAllowed.mockResolvedValue(true);
+
+ const availableTools = {
+ [`test-tool${D}test-server`]: {
+ function: {
+ description: 'Test tool',
+ parameters: { type: 'object', properties: {} },
+ },
+ },
+ };
+
+ // Call with admin user
+ await createMCPTool({
+ res: mockRes,
+ user: adminUser,
+ toolKey: `test-tool${D}test-server`,
+ provider: 'openai',
+ userMCPAuthMap: {},
+ availableTools,
+ });
+
+ // Reset and call with regular user
+ mockRegistryInstance.getServerConfig.mockResolvedValue({
+ url: 'https://some-domain.com/sse',
+ });
+
+ await createMCPTool({
+ res: mockRes,
+ user: regularUser,
+ toolKey: `test-tool${D}test-server`,
+ provider: 'openai',
+ userMCPAuthMap: {},
+ availableTools,
+ });
+
+ // Verify getAppConfig was called with correct roles
+ expect(mockGetAppConfig).toHaveBeenNthCalledWith(1, { role: 'admin' });
+ expect(mockGetAppConfig).toHaveBeenNthCalledWith(2, { role: 'user' });
+ });
+ });
+
+ describe('createUnavailableToolStub', () => {
+ it('should return a tool whose _call returns a valid CONTENT_AND_ARTIFACT two-tuple', async () => {
+ const stub = createUnavailableToolStub('myTool', 'myServer');
+ // invoke() goes through langchain's base tool, which checks responseFormat.
+ // CONTENT_AND_ARTIFACT requires [content, artifact] — a bare string would throw:
+ // "Tool response format is "content_and_artifact" but the output was not a two-tuple"
+ const result = await stub.invoke({});
+ // If we reach here without throwing, the two-tuple format is correct.
+ // invoke() returns the content portion of [content, artifact] as a string.
+ expect(result).toContain('temporarily unavailable');
+ });
+ });
+
+ describe('negative tool cache and throttle interaction', () => {
+ it('should cache tool as missing even when throttled (cross-user dedup)', async () => {
+ const mockUser = { id: 'throttle-test-user' };
+ const mockRes = { write: jest.fn(), flush: jest.fn() };
+
+ // First call: reconnect succeeds but tool not found
+ mockReinitMCPServer.mockResolvedValueOnce({
+ availableTools: {},
+ });
+
+ await createMCPTool({
+ res: mockRes,
+ user: mockUser,
+ toolKey: `missing-tool${D}cache-dedup-server`,
+ provider: 'openai',
+ userMCPAuthMap: {},
+ availableTools: undefined,
+ });
+
+ // Second call within 10s for DIFFERENT tool on same server:
+ // reconnect is throttled (returns null), tool is still cached as missing.
+ // This is intentional: the cache acts as cross-user dedup since the
+ // throttle is per-user-per-server and can't prevent N different users
+ // from each triggering their own reconnect.
+ const result2 = await createMCPTool({
+ res: mockRes,
+ user: mockUser,
+ toolKey: `other-tool${D}cache-dedup-server`,
+ provider: 'openai',
+ userMCPAuthMap: {},
+ availableTools: undefined,
+ });
+
+ expect(result2).toBeDefined();
+ expect(result2.name).toContain('other-tool');
+ expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
+ });
+
+ it('should prevent user B from triggering reconnect when user A already cached the tool', async () => {
+ const userA = { id: 'cache-user-A' };
+ const userB = { id: 'cache-user-B' };
+ const mockRes = { write: jest.fn(), flush: jest.fn() };
+
+ // User A: real reconnect, tool not found → cached
+ mockReinitMCPServer.mockResolvedValueOnce({
+ availableTools: {},
+ });
+
+ await createMCPTool({
+ res: mockRes,
+ user: userA,
+ toolKey: `shared-tool${D}cross-user-server`,
+ provider: 'openai',
+ userMCPAuthMap: {},
+ availableTools: undefined,
+ });
+
+ expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
+
+ // User B requests the SAME tool within 10s.
+ // The negative cache is keyed by toolKey (no user prefix), so user B
+ // gets a cache hit and no reconnect fires. This is the cross-user
+ // storm protection: without this, user B's unthrottled first request
+ // would trigger a second reconnect to the same server.
+ const result = await createMCPTool({
+ res: mockRes,
+ user: userB,
+ toolKey: `shared-tool${D}cross-user-server`,
+ provider: 'openai',
+ userMCPAuthMap: {},
+ availableTools: undefined,
+ });
+
+ expect(result).toBeDefined();
+ expect(result.name).toContain('shared-tool');
+ // reinitMCPServer still called only once — user B hit the cache
+ expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
+ });
+
+ it('should prevent user B from triggering reconnect for throttle-cached tools', async () => {
+ const userA = { id: 'storm-user-A' };
+ const userB = { id: 'storm-user-B' };
+ const mockRes = { write: jest.fn(), flush: jest.fn() };
+
+ // User A: real reconnect for tool-1, tool not found → cached
+ mockReinitMCPServer.mockResolvedValueOnce({
+ availableTools: {},
+ });
+
+ await createMCPTool({
+ res: mockRes,
+ user: userA,
+ toolKey: `tool-1${D}storm-server`,
+ provider: 'openai',
+ userMCPAuthMap: {},
+ availableTools: undefined,
+ });
+
+ // User A: tool-2 on same server within 10s → throttled → cached from throttle
+ await createMCPTool({
+ res: mockRes,
+ user: userA,
+ toolKey: `tool-2${D}storm-server`,
+ provider: 'openai',
+ userMCPAuthMap: {},
+ availableTools: undefined,
+ });
+
+ expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
+
+ // User B requests tool-2 — gets cache hit from the throttle-cached entry.
+ // Without this caching, user B would trigger a real reconnect since
+ // user B has their own throttle key and hasn't reconnected yet.
+ const result = await createMCPTool({
+ res: mockRes,
+ user: userB,
+ toolKey: `tool-2${D}storm-server`,
+ provider: 'openai',
+ userMCPAuthMap: {},
+ availableTools: undefined,
+ });
+
+ expect(result).toBeDefined();
+ expect(result.name).toContain('tool-2');
+ // Still only 1 real reconnect — user B was protected by the cache
+ expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('createMCPTools throttle handling', () => {
+ it('should return empty array with debug log when reconnect is throttled', async () => {
+ const mockUser = { id: 'throttle-tools-user' };
+ const mockRes = { write: jest.fn(), flush: jest.fn() };
+
+ // First call: real reconnect
+ mockReinitMCPServer.mockResolvedValueOnce({
+ tools: [{ name: 'tool1' }],
+ availableTools: {
+ [`tool1${D}throttle-tools-server`]: {
+ function: { description: 'Tool 1', parameters: {} },
+ },
+ },
+ });
+
+ await createMCPTools({
+ res: mockRes,
+ user: mockUser,
+ serverName: 'throttle-tools-server',
+ provider: 'openai',
+ userMCPAuthMap: {},
+ });
+
+ // Second call within 10s — throttled
+ const result = await createMCPTools({
+ res: mockRes,
+ user: mockUser,
+ serverName: 'throttle-tools-server',
+ provider: 'openai',
+ userMCPAuthMap: {},
+ });
+
+ expect(result).toEqual([]);
+ // reinitMCPServer called only once — second was throttled
+ expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
+ // Should log at debug level (not warn) for throttled case
+ expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('Reconnect throttled'));
+ });
+ });
+
describe('User parameter integrity', () => {
it('should preserve user object properties through the call chain', async () => {
const complexUser = {
@@ -850,7 +1297,7 @@ describe('User parameter passing tests', () => {
return Promise.resolve({
tools: [{ name: 'test' }],
availableTools: {
- 'test::server': { function: { description: 'Test', parameters: {} } },
+ [`test${D}server`]: { function: { description: 'Test', parameters: {} } },
},
});
});
diff --git a/api/server/services/ModelService.js b/api/server/services/ModelService.js
deleted file mode 100644
index 28660c4795..0000000000
--- a/api/server/services/ModelService.js
+++ /dev/null
@@ -1,352 +0,0 @@
-const axios = require('axios');
-const { Providers } = require('@librechat/agents');
-const { logger } = require('@librechat/data-schemas');
-const { HttpsProxyAgent } = require('https-proxy-agent');
-const { logAxiosError, inputSchema, processModelData } = require('@librechat/api');
-const { EModelEndpoint, defaultModels, CacheKeys } = require('librechat-data-provider');
-const { OllamaClient } = require('~/app/clients/OllamaClient');
-const { isUserProvided } = require('~/server/utils');
-const getLogStores = require('~/cache/getLogStores');
-const { extractBaseURL } = require('~/utils');
-
-/**
- * Splits a string by commas and trims each resulting value.
- * @param {string} input - The input string to split.
- * @returns {string[]} An array of trimmed values.
- */
-const splitAndTrim = (input) => {
- if (!input || typeof input !== 'string') {
- return [];
- }
- return input
- .split(',')
- .map((item) => item.trim())
- .filter(Boolean);
-};
-
-const { openAIApiKey, userProvidedOpenAI } = require('./Config/EndpointService').config;
-
-/**
- * Fetches OpenAI models from the specified base API path or Azure, based on the provided configuration.
- *
- * @param {Object} params - The parameters for fetching the models.
- * @param {Object} params.user - The user ID to send to the API.
- * @param {string} params.apiKey - The API key for authentication with the API.
- * @param {string} params.baseURL - The base path URL for the API.
- * @param {string} [params.name='OpenAI'] - The name of the API; defaults to 'OpenAI'.
- * @param {boolean} [params.direct=false] - Whether `directEndpoint` was configured
- * @param {boolean} [params.azure=false] - Whether to fetch models from Azure.
- * @param {boolean} [params.userIdQuery=false] - Whether to send the user ID as a query parameter.
- * @param {boolean} [params.createTokenConfig=true] - Whether to create a token configuration from the API response.
- * @param {string} [params.tokenKey] - The cache key to save the token configuration. Uses `name` if omitted.
- * @param {Record} [params.headers] - Optional headers for the request.
- * @param {Partial} [params.userObject] - Optional user object for header resolution.
- * @returns {Promise} A promise that resolves to an array of model identifiers.
- * @async
- */
-const fetchModels = async ({
- user,
- apiKey,
- baseURL: _baseURL,
- name = EModelEndpoint.openAI,
- direct,
- azure = false,
- userIdQuery = false,
- createTokenConfig = true,
- tokenKey,
- headers,
- userObject,
-}) => {
- let models = [];
- const baseURL = direct ? extractBaseURL(_baseURL) : _baseURL;
-
- if (!baseURL && !azure) {
- return models;
- }
-
- if (!apiKey) {
- return models;
- }
-
- if (name && name.toLowerCase().startsWith(Providers.OLLAMA)) {
- try {
- return await OllamaClient.fetchModels(baseURL, { headers, user: userObject });
- } catch (ollamaError) {
- const logMessage =
- 'Failed to fetch models from Ollama API. Attempting to fetch via OpenAI-compatible endpoint.';
- logAxiosError({ message: logMessage, error: ollamaError });
- }
- }
-
- try {
- const options = {
- headers: {
- ...(headers ?? {}),
- },
- timeout: 5000,
- };
-
- if (name === EModelEndpoint.anthropic) {
- options.headers = {
- 'x-api-key': apiKey,
- 'anthropic-version': process.env.ANTHROPIC_VERSION || '2023-06-01',
- };
- } else {
- options.headers.Authorization = `Bearer ${apiKey}`;
- }
-
- if (process.env.PROXY) {
- options.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
- }
-
- if (process.env.OPENAI_ORGANIZATION && baseURL.includes('openai')) {
- options.headers['OpenAI-Organization'] = process.env.OPENAI_ORGANIZATION;
- }
-
- const url = new URL(`${baseURL}${azure ? '' : '/models'}`);
- if (user && userIdQuery) {
- url.searchParams.append('user', user);
- }
- const res = await axios.get(url.toString(), options);
-
- /** @type {z.infer} */
- const input = res.data;
-
- const validationResult = inputSchema.safeParse(input);
- if (validationResult.success && createTokenConfig) {
- const endpointTokenConfig = processModelData(input);
- const cache = getLogStores(CacheKeys.TOKEN_CONFIG);
- await cache.set(tokenKey ?? name, endpointTokenConfig);
- }
- models = input.data.map((item) => item.id);
- } catch (error) {
- const logMessage = `Failed to fetch models from ${azure ? 'Azure ' : ''}${name} API`;
- logAxiosError({ message: logMessage, error });
- }
-
- return models;
-};
-
-/**
- * Fetches models from the specified API path or Azure, based on the provided options.
- * @async
- * @function
- * @param {object} opts - The options for fetching the models.
- * @param {string} opts.user - The user ID to send to the API.
- * @param {boolean} [opts.azure=false] - Whether to fetch models from Azure.
- * @param {boolean} [opts.assistants=false] - Whether to fetch models from Azure.
- * @param {boolean} [opts.plugins=false] - Whether to fetch models from the plugins.
- * @param {string[]} [_models=[]] - The models to use as a fallback.
- */
-const fetchOpenAIModels = async (opts, _models = []) => {
- let models = _models.slice() ?? [];
- let apiKey = openAIApiKey;
- const openaiBaseURL = 'https://api.openai.com/v1';
- let baseURL = openaiBaseURL;
- let reverseProxyUrl = process.env.OPENAI_REVERSE_PROXY;
-
- if (opts.assistants && process.env.ASSISTANTS_BASE_URL) {
- reverseProxyUrl = process.env.ASSISTANTS_BASE_URL;
- } else if (opts.azure) {
- return models;
- // const azure = getAzureCredentials();
- // baseURL = (genAzureChatCompletion(azure))
- // .split('/deployments')[0]
- // .concat(`/models?api-version=${azure.azureOpenAIApiVersion}`);
- // apiKey = azureOpenAIApiKey;
- }
-
- if (reverseProxyUrl) {
- baseURL = extractBaseURL(reverseProxyUrl);
- }
-
- const modelsCache = getLogStores(CacheKeys.MODEL_QUERIES);
-
- const cachedModels = await modelsCache.get(baseURL);
- if (cachedModels) {
- return cachedModels;
- }
-
- if (baseURL || opts.azure) {
- models = await fetchModels({
- apiKey,
- baseURL,
- azure: opts.azure,
- user: opts.user,
- name: EModelEndpoint.openAI,
- });
- }
-
- if (models.length === 0) {
- return _models;
- }
-
- if (baseURL === openaiBaseURL) {
- const regex = /(text-davinci-003|gpt-|o\d+)/;
- const excludeRegex = /audio|realtime/;
- models = models.filter((model) => regex.test(model) && !excludeRegex.test(model));
- const instructModels = models.filter((model) => model.includes('instruct'));
- const otherModels = models.filter((model) => !model.includes('instruct'));
- models = otherModels.concat(instructModels);
- }
-
- await modelsCache.set(baseURL, models);
- return models;
-};
-
-/**
- * Loads the default models for the application.
- * @async
- * @function
- * @param {object} opts - The options for fetching the models.
- * @param {string} opts.user - The user ID to send to the API.
- * @param {boolean} [opts.azure=false] - Whether to fetch models from Azure.
- * @param {boolean} [opts.plugins=false] - Whether to fetch models for the plugins endpoint.
- * @param {boolean} [opts.assistants=false] - Whether to fetch models for the Assistants endpoint.
- */
-const getOpenAIModels = async (opts) => {
- let models = defaultModels[EModelEndpoint.openAI];
-
- if (opts.assistants) {
- models = defaultModels[EModelEndpoint.assistants];
- } else if (opts.azure) {
- models = defaultModels[EModelEndpoint.azureAssistants];
- }
-
- if (opts.plugins) {
- models = models.filter(
- (model) =>
- !model.includes('text-davinci') &&
- !model.includes('instruct') &&
- !model.includes('0613') &&
- !model.includes('0314') &&
- !model.includes('0301'),
- );
- }
-
- let key;
- if (opts.assistants) {
- key = 'ASSISTANTS_MODELS';
- } else if (opts.azure) {
- key = 'AZURE_OPENAI_MODELS';
- } else if (opts.plugins) {
- key = 'PLUGIN_MODELS';
- } else {
- key = 'OPENAI_MODELS';
- }
-
- if (process.env[key]) {
- models = splitAndTrim(process.env[key]);
- return models;
- }
-
- if (userProvidedOpenAI) {
- return models;
- }
-
- return await fetchOpenAIModels(opts, models);
-};
-
-const getChatGPTBrowserModels = () => {
- let models = ['text-davinci-002-render-sha', 'gpt-4'];
- if (process.env.CHATGPT_MODELS) {
- models = splitAndTrim(process.env.CHATGPT_MODELS);
- }
-
- return models;
-};
-
-/**
- * Fetches models from the Anthropic API.
- * @async
- * @function
- * @param {object} opts - The options for fetching the models.
- * @param {string} opts.user - The user ID to send to the API.
- * @param {string[]} [_models=[]] - The models to use as a fallback.
- */
-const fetchAnthropicModels = async (opts, _models = []) => {
- let models = _models.slice() ?? [];
- let apiKey = process.env.ANTHROPIC_API_KEY;
- const anthropicBaseURL = 'https://api.anthropic.com/v1';
- let baseURL = anthropicBaseURL;
- let reverseProxyUrl = process.env.ANTHROPIC_REVERSE_PROXY;
-
- if (reverseProxyUrl) {
- baseURL = extractBaseURL(reverseProxyUrl);
- }
-
- if (!apiKey) {
- return models;
- }
-
- const modelsCache = getLogStores(CacheKeys.MODEL_QUERIES);
-
- const cachedModels = await modelsCache.get(baseURL);
- if (cachedModels) {
- return cachedModels;
- }
-
- if (baseURL) {
- models = await fetchModels({
- apiKey,
- baseURL,
- user: opts.user,
- name: EModelEndpoint.anthropic,
- tokenKey: EModelEndpoint.anthropic,
- });
- }
-
- if (models.length === 0) {
- return _models;
- }
-
- await modelsCache.set(baseURL, models);
- return models;
-};
-
-const getAnthropicModels = async (opts = {}) => {
- let models = defaultModels[EModelEndpoint.anthropic];
- if (process.env.ANTHROPIC_MODELS) {
- models = splitAndTrim(process.env.ANTHROPIC_MODELS);
- return models;
- }
-
- if (isUserProvided(process.env.ANTHROPIC_API_KEY)) {
- return models;
- }
-
- try {
- return await fetchAnthropicModels(opts, models);
- } catch (error) {
- logger.error('Error fetching Anthropic models:', error);
- return models;
- }
-};
-
-const getGoogleModels = () => {
- let models = defaultModels[EModelEndpoint.google];
- if (process.env.GOOGLE_MODELS) {
- models = splitAndTrim(process.env.GOOGLE_MODELS);
- }
-
- return models;
-};
-
-const getBedrockModels = () => {
- let models = defaultModels[EModelEndpoint.bedrock];
- if (process.env.BEDROCK_AWS_MODELS) {
- models = splitAndTrim(process.env.BEDROCK_AWS_MODELS);
- }
-
- return models;
-};
-
-module.exports = {
- fetchModels,
- splitAndTrim,
- getOpenAIModels,
- getBedrockModels,
- getChatGPTBrowserModels,
- getAnthropicModels,
- getGoogleModels,
-};
diff --git a/api/server/services/PermissionService.js b/api/server/services/PermissionService.js
index 4705eadb53..a843f48f6f 100644
--- a/api/server/services/PermissionService.js
+++ b/api/server/services/PermissionService.js
@@ -12,6 +12,7 @@ const {
const {
findAccessibleResources: findAccessibleResourcesACL,
getEffectivePermissions: getEffectivePermissionsACL,
+ getEffectivePermissionsForResources: getEffectivePermissionsForResourcesACL,
grantPermission: grantPermissionACL,
findEntriesByPrincipalsAndResource,
findGroupByExternalId,
@@ -140,7 +141,6 @@ const checkPermission = async ({ userId, role, resourceType, resourceId, require
validateResourceType(resourceType);
- // Get all principals for the user (user + groups + public)
const principals = await getUserPrincipals({ userId, role });
if (principals.length === 0) {
@@ -150,7 +150,6 @@ const checkPermission = async ({ userId, role, resourceType, resourceId, require
return await hasPermission(principals, resourceType, resourceId, requiredPermission);
} catch (error) {
logger.error(`[PermissionService.checkPermission] Error: ${error.message}`);
- // Re-throw validation errors
if (error.message.includes('requiredPermission must be')) {
throw error;
}
@@ -171,12 +170,12 @@ const getEffectivePermissions = async ({ userId, role, resourceType, resourceId
try {
validateResourceType(resourceType);
- // Get all principals for the user (user + groups + public)
const principals = await getUserPrincipals({ userId, role });
if (principals.length === 0) {
return 0;
}
+
return await getEffectivePermissionsACL(principals, resourceType, resourceId);
} catch (error) {
logger.error(`[PermissionService.getEffectivePermissions] Error: ${error.message}`);
@@ -184,6 +183,49 @@ const getEffectivePermissions = async ({ userId, role, resourceType, resourceId
}
};
+/**
+ * Get effective permissions for multiple resources in a batch operation
+ * Returns map of resourceId → effectivePermissionBits
+ *
+ * @param {Object} params - Parameters
+ * @param {string|mongoose.Types.ObjectId} params.userId - User ID
+ * @param {string} [params.role] - User role (for group membership)
+ * @param {string} params.resourceType - Resource type (must be valid ResourceType)
+ * @param {Array} params.resourceIds - Array of resource IDs
+ * @returns {Promise