Merge branch 'main' into claude/fix-mcp-accent-support-UBEjT

This commit is contained in:
Lionel Ringenbach 2026-04-01 21:02:09 -07:00 committed by GitHub
commit 35fe995b52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
1014 changed files with 73617 additions and 26878 deletions

View file

@ -64,6 +64,8 @@ CONSOLE_JSON=false
DEBUG_LOGGING=true
DEBUG_CONSOLE=false
# Set to true to enable agent debug logging
AGENT_DEBUG_LOGGING=false
# Enable memory diagnostics (logs heap/RSS snapshots every 60s, auto-enabled with --inspect)
# MEM_DIAG=true
@ -540,6 +542,8 @@ OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE="user.read" # example for Scope Needed for
OPENID_USE_END_SESSION_ENDPOINT=
# URL to redirect to after OpenID logout (defaults to ${DOMAIN_CLIENT}/login)
OPENID_POST_LOGOUT_REDIRECT_URI=
# Maximum logout URL length before using logout_hint instead of id_token_hint (default: 2000)
OPENID_MAX_LOGOUT_URL_LENGTH=
#========================#
# SharePoint Integration #
@ -623,6 +627,7 @@ EMAIL_PORT=25
EMAIL_ENCRYPTION=
EMAIL_ENCRYPTION_HOSTNAME=
EMAIL_ALLOW_SELFSIGNED=
# Leave both empty for SMTP servers that do not require authentication
EMAIL_USERNAME=
EMAIL_PASSWORD=
EMAIL_FROM_NAME=

3
.gitattributes vendored Normal file
View file

@ -0,0 +1,3 @@
# Force LF line endings for shell scripts and git hooks (required for cross-platform compatibility)
.husky/* text eol=lf
*.sh text eol=lf

View file

@ -97,6 +97,65 @@ jobs:
path: packages/api/dist
retention-days: 2
typecheck:
name: TypeScript type 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: Download api build
uses: actions/download-artifact@v4
with:
name: build-api
path: packages/api/dist
- name: Type check data-provider
run: npx tsc --noEmit -p packages/data-provider/tsconfig.json
- name: Type check data-schemas
run: npx tsc --noEmit -p packages/data-schemas/tsconfig.json
- name: Type check @librechat/api
run: npx tsc --noEmit -p packages/api/tsconfig.json
- name: Type check @librechat/client
run: npx tsc --noEmit -p packages/client/tsconfig.json
circular-deps:
name: Circular dependency checks
needs: build

26
.gitignore vendored
View file

@ -63,6 +63,7 @@ bower_components/
.clineignore
.cursor
.aider*
.bg-shell/
# Floobits
.floo
@ -129,6 +130,7 @@ helm/**/charts/
helm/**/.values.yaml
!/client/src/@types/i18next.d.ts
!/client/src/@types/react.d.ts
# SAML Idp cert
*.cert
@ -143,7 +145,6 @@ helm/**/.values.yaml
/.codeium
*.local.md
# Removed Windows wrapper files per user request
hive-mind-prompt-*.txt
@ -154,16 +155,16 @@ claude-flow.config.json
.swarm/
.hive-mind/
.claude-flow/
memory/
coordination/
memory/claude-flow-data.json
memory/sessions/*
!memory/sessions/README.md
memory/agents/*
!memory/agents/README.md
coordination/memory_bank/*
coordination/subtasks/*
coordination/orchestration/*
/memory/
/coordination/
/memory/claude-flow-data.json
/memory/sessions/*
!/memory/sessions/README.md
/memory/agents/*
!/memory/agents/README.md
/coordination/memory_bank/*
/coordination/subtasks/*
/coordination/orchestration/*
*.db
*.db-journal
*.db-wal
@ -171,5 +172,8 @@ coordination/orchestration/*
*.sqlite-journal
*.sqlite-wal
claude-flow
.playwright-mcp/*
# Removed Windows wrapper files per user request
hive-mind-prompt-*.txt
CLAUDE.md
.gsd

View file

@ -1,2 +1,3 @@
#!/bin/sh
[ -n "$CI" ] && exit 0
npx lint-staged --config ./.husky/lint-staged.config.js

167
AGENTS.md
View file

@ -1,166 +1 @@
# 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<string, unknown>`, and `as unknown as T` assertions. A `Record<string, unknown>` 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 <pattern>`, `cd packages/api && npx jest <pattern>`, 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.
CLAUDE.md

View file

@ -1 +0,0 @@
AGENTS.md

172
CLAUDE.md Normal file
View file

@ -0,0 +1,172 @@
# 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
### Naming and File Organization
- **Single-word file names** whenever possible (e.g., `permissions.ts`, `capabilities.ts`, `service.ts`).
- When multiple words are needed, prefer grouping related modules under a **single-word directory** rather than using multi-word file names (e.g., `admin/capabilities.ts` not `adminCapabilities.ts`).
- The directory already provides context — `app/service.ts` not `app/appConfigService.ts`.
### 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<string, unknown>`, and `as unknown as T` assertions. A `Record<string, unknown>` 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 <pattern>`, `cd packages/api && npx jest <pattern>`, 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.

View file

@ -1,9 +1,9 @@
# v0.8.3
# v0.8.4
# Base node image
FROM node:20-alpine AS node
# Install jemalloc
RUN apk upgrade --no-cache
RUN apk add --no-cache jemalloc
RUN apk add --no-cache python3 py3-pip uv

View file

@ -1,12 +1,12 @@
# Dockerfile.multi
# v0.8.3
# v0.8.4
# 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
# Install jemalloc
RUN apk upgrade --no-cache
RUN apk add --no-cache jemalloc
# Set environment variable to use jemalloc
ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2

View file

@ -7,6 +7,11 @@
</h1>
</p>
<p align="center">
<strong>English</strong> ·
<a href="README.zh.md">中文</a>
</p>
<p align="center">
<a href="https://discord.librechat.ai">
<img
@ -27,7 +32,7 @@
</p>
<p align="center">
<a href="https://railway.com/deploy/b5k2mn?referralCode=HI9hWz">
<a href="https://railway.com/deploy/librechat-official?referralCode=HI9hWz&utm_medium=integration&utm_source=readme&utm_campaign=librechat">
<img src="https://railway.com/button.svg" alt="Deploy on Railway" height="30">
</a>
<a href="https://zeabur.com/templates/0X2ZY8">

227
README.zh.md Normal file
View file

@ -0,0 +1,227 @@
<!-- Last synced with README.md: 2026-03-28 (cae3888) -->
<p align="center">
<a href="https://librechat.ai">
<img src="client/public/assets/logo.svg" height="256">
</a>
<h1 align="center">
<a href="https://librechat.ai">LibreChat</a>
</h1>
</p>
<p align="center">
<a href="README.md">English</a> ·
<strong>中文</strong>
</p>
<p align="center">
<a href="https://discord.librechat.ai">
<img
src="https://img.shields.io/discord/1086345563026489514?label=&logo=discord&style=for-the-badge&logoWidth=20&logoColor=white&labelColor=000000&color=blueviolet">
</a>
<a href="https://www.youtube.com/@LibreChat">
<img
src="https://img.shields.io/badge/YOUTUBE-red.svg?style=for-the-badge&logo=youtube&logoColor=white&labelColor=000000&logoWidth=20">
</a>
<a href="https://docs.librechat.ai">
<img
src="https://img.shields.io/badge/DOCS-blue.svg?style=for-the-badge&logo=read-the-docs&logoColor=white&labelColor=000000&logoWidth=20">
</a>
<a aria-label="Sponsors" href="https://github.com/sponsors/danny-avila">
<img
src="https://img.shields.io/badge/SPONSORS-brightgreen.svg?style=for-the-badge&logo=github-sponsors&logoColor=white&labelColor=000000&logoWidth=20">
</a>
</p>
<p align="center">
<a href="https://railway.com/deploy/librechat-official?referralCode=HI9hWz&utm_medium=integration&utm_source=readme&utm_campaign=librechat">
<img src="https://railway.com/button.svg" alt="Deploy on Railway" height="30">
</a>
<a href="https://zeabur.com/templates/0X2ZY8">
<img src="https://zeabur.com/button.svg" alt="Deploy on Zeabur" height="30"/>
</a>
<a href="https://template.cloud.sealos.io/deploy?templateName=librechat">
<img src="https://raw.githubusercontent.com/labring-actions/templates/main/Deploy-on-Sealos.svg" alt="Deploy on Sealos" height="30">
</a>
</p>
<p align="center">
<a href="https://www.librechat.ai/docs/translation">
<img
src="https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&color=2096F3&label=locize&query=%24.translatedPercentage&url=https://api.locize.app/badgedata/4cb2598b-ed4d-469c-9b04-2ed531a8cb45&suffix=%+translated"
alt="翻译进度">
</a>
</p>
# ✨ 功能
- 🖥️ **UI 与体验**:受 ChatGPT 启发,并具备更强的设计与功能。
- 🤖 **AI 模型选择**
- Anthropic (Claude), AWS Bedrock, OpenAI, Azure OpenAI, Google, Vertex AI, OpenAI Responses API (包含 Azure)
- [自定义端点 (Custom Endpoints)](https://www.librechat.ai/docs/quick_start/custom_endpoints)LibreChat 支持任何兼容 OpenAI 规范的 API无需代理。
- 兼容[本地与远程 AI 服务商](https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints)
- Ollama, groq, Cohere, Mistral AI, Apple MLX, koboldcpp, together.ai,
- OpenRouter, Helicone, Perplexity, ShuttleAI, Deepseek, Qwen 等。
- 🔧 **[代码解释器 (Code Interpreter) API](https://www.librechat.ai/docs/features/code_interpreter)**
- 安全的沙箱执行环境,支持 Python, Node.js (JS/TS), Go, C/C++, Java, PHP, Rust 和 Fortran。
- 无缝文件处理:直接上传、处理并下载文件。
- 隐私无忧:完全隔离且安全的执行环境。
- 🔦 **智能体与工具集成**
- **[LibreChat 智能体 (Agents)](https://www.librechat.ai/docs/features/agents)**
- 无代码定制助手:无需编程即可构建专业化的 AI 驱动助手。
- 智能体市场:发现并部署社区构建的智能体。
- 协作共享:与特定用户和群组共享智能体。
- 灵活且可扩展:支持 MCP 服务器、工具、文件搜索、代码执行等。
- 兼容自定义端点、OpenAI, Azure, Anthropic, AWS Bedrock, Google, Vertex AI, Responses API 等。
- [支持模型上下文协议 (MCP)](https://modelcontextprotocol.io/clients#librechat) 用于工具调用。
- 🔍 **网页搜索**
- 搜索互联网并检索相关信息以增强 AI 上下文。
- 结合搜索提供商、内容爬虫和结果重排序,确保最佳检索效果。
- **可定制 Jina 重排序**:配置自定义 Jina API URL 用于重排序服务。
- **[了解更多 →](https://www.librechat.ai/docs/features/web_search)**
- 🪄 **支持代码 Artifacts 的生成式 UI**
- [代码 Artifacts](https://youtu.be/GfTj7O4gmd0?si=WJbdnemZpJzBrJo3) 允许在对话中直接创建 React 组件、HTML 页面和 Mermaid 图表。
- 🎨 **图像生成与编辑**
- 使用 [GPT-Image-1](https://www.librechat.ai/docs/features/image_gen#1--openai-image-tools-recommended) 进行文生图与图生图。
- 支持 [DALL-E (3/2)](https://www.librechat.ai/docs/features/image_gen#2--dalle-legacy), [Stable Diffusion](https://www.librechat.ai/docs/features/image_gen#3--stable-diffusion-local), [Flux](https://www.librechat.ai/docs/features/image_gen#4--flux) 或任何 [MCP 服务器](https://www.librechat.ai/docs/features/image_gen#5--model-context-protocol-mcp)。
- 根据提示词生成惊艳的视觉效果,或通过指令精修现有图像。
- 💾 **预设与上下文管理**
- 创建、保存并分享自定义预设。
- 在对话中随时切换 AI 端点和预设。
- 编辑、重新提交并通过对话分支继续消息。
- 创建并与特定用户和群组共享提示词。
- [消息与对话分叉 (Fork)](https://www.librechat.ai/docs/features/fork) 以实现高级上下文控制。
- 💬 **多模态与文件交互**
- 使用 Claude 3, GPT-4.5, GPT-4o, o1, Llama-Vision 和 Gemini 上传并分析图像 📸。
- 支持通过自定义端点、OpenAI, Azure, Anthropic, AWS Bedrock 和 Google 进行文件对话 🗃️。
- 🌎 **多语言 UI**
- English, 中文 (简体), 中文 (繁體), العربية, Deutsch, Español, Français, Italiano
- Polski, Português (PT), Português (BR), Русский, 日本語, Svenska, 한국어, Tiếng Việt
- Türkçe, Nederlands, עברית, Català, Čeština, Dansk, Eesti, فارسی
- Suomi, Magyar, Հայերեն, Bahasa Indonesia, ქართული, Latviešu, ไทย, ئۇيغۇرچە
- 🧠 **推理 UI**
- 针对 DeepSeek-R1 等思维链/推理 AI 模型的动态推理 UI。
- 🎨 **可定制界面**
- 可定制的下拉菜单和界面,同时适配高级用户和初学者。
- 🌊 **[可恢复流 (Resumable Streams)](https://www.librechat.ai/docs/features/resumable_streams)**
- 永不丢失响应AI 响应在连接中断后自动重连并继续。
- 多标签页与多设备同步:在多个标签页打开同一对话,或在另一设备上继续。
- 生产级可靠性:支持从单机部署到基于 Redis 的水平扩展。
- 🗣️ **语音与音频**
- 通过语音转文字和文字转语音实现免提对话。
- 自动发送并播放音频。
- 支持 OpenAI, Azure OpenAI 和 Elevenlabs。
- 📥 **导入与导出对话**
- 从 LibreChat, ChatGPT, Chatbot UI 导入对话。
- 将对话导出为截图、Markdown、文本、JSON。
- 🔍 **搜索与发现**
- 搜索所有消息和对话。
- 👥 **多用户与安全访问**
- 支持 OAuth2, LDAP 和电子邮件登录的多用户安全认证。
- 内置审核系统和 Token 消耗管理工具。
- ⚙️ **配置与部署**
- 支持代理、反向代理、Docker 及多种部署选项。
- 可完全本地运行或部署在云端。
- 📖 **开源与社区**
- 完全开源且在公众监督下开发。
- 社区驱动的开发、支持与反馈。
[查看我们的文档了解更多功能详情](https://docs.librechat.ai/) 📚
## 🪶 LibreChat全方位的 AI 对话平台
LibreChat 是一个自托管的 AI 对话平台,在一个注重隐私的统一界面中整合了所有主流 AI 服务商。
除了对话功能外LibreChat 还提供 AI 智能体、模型上下文协议 (MCP) 支持、Artifacts、代码解释器、自定义操作、对话搜索以及企业级多用户认证。
开源、活跃开发中,专为重视 AI 基础设施自主可控的用户而构建。
---
## 🌐 资源
**GitHub 仓库:**
- **RAG API:** [github.com/danny-avila/rag_api](https://github.com/danny-avila/rag_api)
- **网站:** [github.com/LibreChat-AI/librechat.ai](https://github.com/LibreChat-AI/librechat.ai)
**其他:**
- **官方网站:** [librechat.ai](https://librechat.ai)
- **帮助文档:** [librechat.ai/docs](https://librechat.ai/docs)
- **博客:** [librechat.ai/blog](https://librechat.ai/blog)
---
## 📝 更新日志
访问发布页面和更新日志以了解最新动态:
- [发布页面 (Releases)](https://github.com/danny-avila/LibreChat/releases)
- [更新日志 (Changelog)](https://www.librechat.ai/changelog)
**⚠️ 在更新前请务必查看[更新日志](https://www.librechat.ai/changelog)以了解破坏性更改。**
---
## ⭐ Star 历史
<p align="center">
<a href="https://star-history.com/#danny-avila/LibreChat&Date">
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=danny-avila/LibreChat&type=Date&theme=dark" onerror="this.src='https://api.star-history.com/svg?repos=danny-avila/LibreChat&type=Date'" />
</a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/4685" target="_blank" style="padding: 10px;">
<img src="https://trendshift.io/api/badge/repositories/4685" alt="danny-avila%2FLibreChat | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
</a>
<a href="https://runacap.com/ross-index/q1-24/" target="_blank" rel="noopener" style="margin-left: 20px;">
<img style="width: 260px; height: 56px" src="https://runacap.com/wp-content/uploads/2024/04/ROSS_badge_white_Q1_2024.svg" alt="ROSS Index - 2024年第一季度增长最快的开源初创公司 | Runa Capital" width="260" height="56"/>
</a>
</p>
---
## ✨ 贡献
欢迎任何形式的贡献、建议、错误报告和修复!
对于新功能、组件或扩展,请在发送 PR 前开启 issue 进行讨论。
如果您想帮助我们将 LibreChat 翻译成您的母语,我们非常欢迎!改进翻译不仅能让全球用户更轻松地使用 LibreChat还能提升整体用户体验。请查看我们的[翻译指南](https://www.librechat.ai/docs/translation)。
---
## 💖 感谢所有贡献者
<a href="https://github.com/danny-avila/LibreChat/graphs/contributors">
<img src="https://contrib.rocks/image?repo=danny-avila/LibreChat" />
</a>
---
## 🎉 特别鸣谢
感谢 [Locize](https://locize.com) 提供的翻译管理工具,支持 LibreChat 的多语言功能。
<p align="center">
<a href="https://locize.com" target="_blank" rel="noopener noreferrer">
<img src="https://github.com/user-attachments/assets/d6b70894-6064-475e-bb65-92a9e23e0077" alt="Locize Logo" height="50">
</a>
</p>

View file

@ -3,6 +3,7 @@ const fetch = require('node-fetch');
const { logger } = require('@librechat/data-schemas');
const {
countTokens,
checkBalance,
getBalanceConfig,
buildMessageFiles,
extractFileContext,
@ -12,35 +13,27 @@ const {
} = require('@librechat/api');
const {
Constants,
ErrorTypes,
FileSources,
ContentTypes,
excludedKeys,
EModelEndpoint,
mergeFileConfig,
isParamEndpoint,
isAgentsEndpoint,
isEphemeralAgentId,
supportsBalanceCheck,
isBedrockDocumentType,
getEndpointFileConfig,
} = require('librechat-data-provider');
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 { logViolation } = require('~/cache');
const TextStream = require('./TextStream');
const db = require('~/models');
class BaseClient {
constructor(apiKey, options = {}) {
this.apiKey = apiKey;
this.sender = options.sender ?? 'AI';
this.contextStrategy = null;
this.currentDateString = new Date().toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
@ -80,6 +73,10 @@ class BaseClient {
this.currentMessages = [];
/** @type {import('librechat-data-provider').VisionModes | undefined} */
this.visionMode;
/** @type {import('librechat-data-provider').FileConfig | undefined} */
this._mergedFileConfig;
/** @type {import('librechat-data-provider').EndpointFileConfig | undefined} */
this._endpointFileConfig;
}
setOptions() {
@ -339,45 +336,6 @@ class BaseClient {
return payload;
}
async handleTokenCountMap(tokenCountMap) {
if (this.clientName === EModelEndpoint.agents) {
return;
}
if (this.currentMessages.length === 0) {
return;
}
for (let i = 0; i < this.currentMessages.length; i++) {
// Skip the last message, which is the user message.
if (i === this.currentMessages.length - 1) {
break;
}
const message = this.currentMessages[i];
const { messageId } = message;
const update = {};
if (messageId === tokenCountMap.summaryMessage?.messageId) {
logger.debug(`[BaseClient] Adding summary props to ${messageId}.`);
update.summary = tokenCountMap.summaryMessage.content;
update.summaryTokenCount = tokenCountMap.summaryMessage.tokenCount;
}
if (message.tokenCount && !update.summaryTokenCount) {
logger.debug(`[BaseClient] Skipping ${messageId}: already had a token count.`);
continue;
}
const tokenCount = tokenCountMap[messageId];
if (tokenCount) {
message.tokenCount = tokenCount;
update.tokenCount = tokenCount;
await this.updateMessageInDatabase({ messageId, ...update });
}
}
}
concatenateMessages(messages) {
return messages.reduce((acc, message) => {
const nameOrRole = message.name ?? message.role;
@ -448,154 +406,6 @@ class BaseClient {
};
}
async handleContextStrategy({
instructions,
orderedMessages,
formattedMessages,
buildTokenMap = true,
}) {
let _instructions;
let tokenCount;
if (instructions) {
({ tokenCount, ..._instructions } = instructions);
}
_instructions && logger.debug('[BaseClient] instructions tokenCount: ' + tokenCount);
if (tokenCount && tokenCount > this.maxContextTokens) {
const info = `${tokenCount} / ${this.maxContextTokens}`;
const errorMessage = `{ "type": "${ErrorTypes.INPUT_LENGTH}", "info": "${info}" }`;
logger.warn(`Instructions token count exceeds max token count (${info}).`);
throw new Error(errorMessage);
}
if (this.clientName === EModelEndpoint.agents) {
const { dbMessages, editedIndices } = truncateToolCallOutputs(
orderedMessages,
this.maxContextTokens,
this.getTokenCountForMessage.bind(this),
);
if (editedIndices.length > 0) {
logger.debug('[BaseClient] Truncated tool call outputs:', editedIndices);
for (const index of editedIndices) {
formattedMessages[index].content = dbMessages[index].content;
}
orderedMessages = dbMessages;
}
}
let orderedWithInstructions = this.addInstructions(orderedMessages, instructions);
let { context, remainingContextTokens, messagesToRefine } =
await this.getMessagesWithinTokenLimit({
messages: orderedWithInstructions,
instructions,
});
logger.debug('[BaseClient] Context Count (1/2)', {
remainingContextTokens,
maxContextTokens: this.maxContextTokens,
});
let summaryMessage;
let summaryTokenCount;
let { shouldSummarize } = this;
// Calculate the difference in length to determine how many messages were discarded if any
let payload;
let { length } = formattedMessages;
length += instructions != null ? 1 : 0;
const diff = length - context.length;
const firstMessage = orderedWithInstructions[0];
const usePrevSummary =
shouldSummarize &&
diff === 1 &&
firstMessage?.summary &&
this.previous_summary.messageId === firstMessage.messageId;
if (diff > 0) {
payload = formattedMessages.slice(diff);
logger.debug(
`[BaseClient] Difference between original payload (${length}) and context (${context.length}): ${diff}`,
);
}
payload = this.addInstructions(payload ?? formattedMessages, _instructions);
const latestMessage = orderedWithInstructions[orderedWithInstructions.length - 1];
if (payload.length === 0 && !shouldSummarize && latestMessage) {
const info = `${latestMessage.tokenCount} / ${this.maxContextTokens}`;
const errorMessage = `{ "type": "${ErrorTypes.INPUT_LENGTH}", "info": "${info}" }`;
logger.warn(`Prompt token count exceeds max token count (${info}).`);
throw new Error(errorMessage);
} else if (
_instructions &&
payload.length === 1 &&
payload[0].content === _instructions.content
) {
const info = `${tokenCount + 3} / ${this.maxContextTokens}`;
const errorMessage = `{ "type": "${ErrorTypes.INPUT_LENGTH}", "info": "${info}" }`;
logger.warn(
`Including instructions, the prompt token count exceeds remaining max token count (${info}).`,
);
throw new Error(errorMessage);
}
if (usePrevSummary) {
summaryMessage = { role: 'system', content: firstMessage.summary };
summaryTokenCount = firstMessage.summaryTokenCount;
payload.unshift(summaryMessage);
remainingContextTokens -= summaryTokenCount;
} else if (shouldSummarize && messagesToRefine.length > 0) {
({ summaryMessage, summaryTokenCount } = await this.summarizeMessages({
messagesToRefine,
remainingContextTokens,
}));
summaryMessage && payload.unshift(summaryMessage);
remainingContextTokens -= summaryTokenCount;
}
// Make sure to only continue summarization logic if the summary message was generated
shouldSummarize = summaryMessage != null && shouldSummarize === true;
logger.debug('[BaseClient] Context Count (2/2)', {
remainingContextTokens,
maxContextTokens: this.maxContextTokens,
});
/** @type {Record<string, number> | undefined} */
let tokenCountMap;
if (buildTokenMap) {
const currentPayload = shouldSummarize ? orderedWithInstructions : context;
tokenCountMap = currentPayload.reduce((map, message, index) => {
const { messageId } = message;
if (!messageId) {
return map;
}
if (shouldSummarize && index === messagesToRefine.length - 1 && !usePrevSummary) {
map.summaryMessage = { ...summaryMessage, messageId, tokenCount: summaryTokenCount };
}
map[messageId] = currentPayload[index].tokenCount;
return map;
}, {});
}
const promptTokens = this.maxContextTokens - remainingContextTokens;
logger.debug('[BaseClient] tokenCountMap:', tokenCountMap);
logger.debug('[BaseClient]', {
promptTokens,
remainingContextTokens,
payloadSize: payload.length,
maxContextTokens: this.maxContextTokens,
});
return { payload, tokenCountMap, promptTokens, messages: orderedWithInstructions };
}
async sendMessage(message, opts = {}) {
const appConfig = this.options.req?.config;
/** @type {Promise<TMessage>} */
@ -664,17 +474,13 @@ class BaseClient {
opts,
);
if (tokenCountMap) {
if (tokenCountMap[userMessage.messageId]) {
userMessage.tokenCount = tokenCountMap[userMessage.messageId];
logger.debug('[BaseClient] userMessage', {
messageId: userMessage.messageId,
tokenCount: userMessage.tokenCount,
conversationId: userMessage.conversationId,
});
}
this.handleTokenCountMap(tokenCountMap);
if (tokenCountMap && tokenCountMap[userMessage.messageId]) {
userMessage.tokenCount = tokenCountMap[userMessage.messageId];
logger.debug('[BaseClient] userMessage', {
messageId: userMessage.messageId,
tokenCount: userMessage.tokenCount,
conversationId: userMessage.conversationId,
});
}
if (!isEdited && !this.skipSaveUserMessage) {
@ -686,7 +492,12 @@ class BaseClient {
}
delete userMessage.image_urls;
}
userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user);
userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user).catch(
(err) => {
logger.error('[BaseClient] Failed to save user message:', err);
return {};
},
);
this.savedMessageIds.add(userMessage.messageId);
if (typeof opts?.getReqData === 'function') {
opts.getReqData({
@ -700,18 +511,28 @@ class BaseClient {
balanceConfig?.enabled &&
supportsBalanceCheck[this.options.endpointType ?? this.options.endpoint]
) {
await checkBalance({
req: this.options.req,
res: this.options.res,
txData: {
user: this.user,
tokenType: 'prompt',
amount: promptTokens,
endpoint: this.options.endpoint,
model: this.modelOptions?.model ?? this.model,
endpointTokenConfig: this.options.endpointTokenConfig,
await checkBalance(
{
req: this.options.req,
res: this.options.res,
txData: {
user: this.user,
tokenType: 'prompt',
amount: promptTokens,
endpoint: this.options.endpoint,
model: this.modelOptions?.model ?? this.model,
endpointTokenConfig: this.options.endpointTokenConfig,
},
},
});
{
logViolation,
getMultiplier: db.getMultiplier,
findBalanceByUser: db.findBalanceByUser,
createAutoRefillTransaction: db.createAutoRefillTransaction,
balanceConfig,
upsertBalanceFields: db.upsertBalanceFields,
},
);
}
const { completion, metadata } = await this.sendCompletion(payload, opts);
@ -764,12 +585,7 @@ class BaseClient {
responseMessage.text = completion.join('');
}
if (
tokenCountMap &&
this.recordTokenUsage &&
this.getTokenCountForResponse &&
this.getTokenCount
) {
if (tokenCountMap && this.recordTokenUsage && this.getTokenCountForResponse) {
let completionTokens;
/**
@ -782,13 +598,6 @@ class BaseClient {
if (usage != null && Number(usage[this.outputTokensKey]) > 0) {
responseMessage.tokenCount = usage[this.outputTokensKey];
completionTokens = responseMessage.tokenCount;
await this.updateUserMessageTokenCount({
usage,
tokenCountMap,
userMessage,
userMessagePromise,
opts,
});
} else {
responseMessage.tokenCount = this.getTokenCountForResponse(responseMessage);
completionTokens = responseMessage.tokenCount;
@ -815,6 +624,27 @@ class BaseClient {
await userMessagePromise;
}
if (
this.contextMeta?.calibrationRatio > 0 &&
this.contextMeta.calibrationRatio !== 1 &&
userMessage.tokenCount > 0
) {
const calibrated = Math.round(userMessage.tokenCount * this.contextMeta.calibrationRatio);
if (calibrated !== userMessage.tokenCount) {
logger.debug('[BaseClient] Calibrated user message tokenCount', {
messageId: userMessage.messageId,
raw: userMessage.tokenCount,
calibrated,
ratio: this.contextMeta.calibrationRatio,
});
userMessage.tokenCount = calibrated;
await this.updateMessageInDatabase({
messageId: userMessage.messageId,
tokenCount: calibrated,
});
}
}
if (this.artifactPromises) {
responseMessage.attachments = (await Promise.all(this.artifactPromises)).filter((a) => a);
}
@ -827,6 +657,10 @@ class BaseClient {
}
}
if (this.contextMeta) {
responseMessage.contextMeta = this.contextMeta;
}
responseMessage.databasePromise = this.saveMessageToDatabase(
responseMessage,
saveOptions,
@ -837,79 +671,10 @@ class BaseClient {
return responseMessage;
}
/**
* Stream usage should only be used for user message token count re-calculation if:
* - The stream usage is available, with input tokens greater than 0,
* - the client provides a function to calculate the current token count,
* - files are being resent with every message (default behavior; or if `false`, with no attachments),
* - the `promptPrefix` (custom instructions) is not set.
*
* In these cases, the legacy token estimations would be more accurate.
*
* TODO: included system messages in the `orderedMessages` accounting, potentially as a
* separate message in the UI. ChatGPT does this through "hidden" system messages.
* @param {object} params
* @param {StreamUsage} params.usage
* @param {Record<string, number>} params.tokenCountMap
* @param {TMessage} params.userMessage
* @param {Promise<TMessage>} params.userMessagePromise
* @param {object} params.opts
*/
async updateUserMessageTokenCount({
usage,
tokenCountMap,
userMessage,
userMessagePromise,
opts,
}) {
/** @type {boolean} */
const shouldUpdateCount =
this.calculateCurrentTokenCount != null &&
Number(usage[this.inputTokensKey]) > 0 &&
(this.options.resendFiles ||
(!this.options.resendFiles && !this.options.attachments?.length)) &&
!this.options.promptPrefix;
if (!shouldUpdateCount) {
return;
}
const userMessageTokenCount = this.calculateCurrentTokenCount({
currentMessageId: userMessage.messageId,
tokenCountMap,
usage,
});
if (userMessageTokenCount === userMessage.tokenCount) {
return;
}
userMessage.tokenCount = userMessageTokenCount;
/*
Note: `AgentController` saves the user message if not saved here
(noted by `savedMessageIds`), so we update the count of its `userMessage` reference
*/
if (typeof opts?.getReqData === 'function') {
opts.getReqData({
userMessage,
});
}
/*
Note: we update the user message to be sure it gets the calculated token count;
though `AgentController` saves the user message if not saved here
(noted by `savedMessageIds`), EditController does not
*/
await userMessagePromise;
await this.updateMessageInDatabase({
messageId: userMessage.messageId,
tokenCount: userMessageTokenCount,
});
}
async loadHistory(conversationId, parentMessageId = null) {
logger.debug('[BaseClient] Loading history:', { conversationId, parentMessageId });
const messages = (await getMessages({ conversationId })) ?? [];
const messages = (await db.getMessages({ conversationId })) ?? [];
if (messages.length === 0) {
return [];
@ -932,10 +697,24 @@ class BaseClient {
return _messages;
}
// Find the latest message with a 'summary' property
for (let i = _messages.length - 1; i >= 0; i--) {
if (_messages[i]?.summary) {
this.previous_summary = _messages[i];
const msg = _messages[i];
if (!msg) {
continue;
}
const summaryBlock = BaseClient.findSummaryContentBlock(msg);
if (summaryBlock) {
this.previous_summary = {
...msg,
summary: BaseClient.getSummaryText(summaryBlock),
summaryTokenCount: summaryBlock.tokenCount,
};
break;
}
if (msg.summary) {
this.previous_summary = msg;
break;
}
}
@ -960,16 +739,30 @@ class BaseClient {
* @param {string | null} user
*/
async saveMessageToDatabase(message, endpointOptions, user = null) {
// Snapshot options before any await; disposeClient may set client.options = null
// while this method is suspended at an I/O boundary, but the local reference
// remains valid (disposeClient nulls the property, not the object itself).
const options = this.options;
if (!options) {
logger.error('[BaseClient] saveMessageToDatabase: client disposed before save, skipping');
return {};
}
if (this.user && user !== this.user) {
throw new Error('User mismatch.');
}
const hasAddedConvo = this.options?.req?.body?.addedConvo != null;
const savedMessage = await saveMessage(
this.options?.req,
const hasAddedConvo = options?.req?.body?.addedConvo != null;
const reqCtx = {
userId: options?.req?.user?.id,
isTemporary: options?.req?.body?.isTemporary,
interfaceConfig: options?.req?.config?.interfaceConfig,
};
const savedMessage = await db.saveMessage(
reqCtx,
{
...message,
endpoint: this.options.endpoint,
endpoint: options.endpoint,
unfinished: false,
user,
...(hasAddedConvo && { addedConvo: true }),
@ -983,20 +776,20 @@ class BaseClient {
const fieldsToKeep = {
conversationId: message.conversationId,
endpoint: this.options.endpoint,
endpointType: this.options.endpointType,
endpoint: options.endpoint,
endpointType: options.endpointType,
...endpointOptions,
};
const existingConvo =
this.fetchedConvo === true
? null
: await getConvo(this.options?.req?.user?.id, message.conversationId);
: await db.getConvo(options?.req?.user?.id, message.conversationId);
const unsetFields = {};
const exceptions = new Set(['spec', 'iconURL']);
const hasNonEphemeralAgent =
isAgentsEndpoint(this.options.endpoint) &&
isAgentsEndpoint(options.endpoint) &&
endpointOptions?.agent_id &&
!isEphemeralAgentId(endpointOptions.agent_id);
if (hasNonEphemeralAgent) {
@ -1018,7 +811,7 @@ class BaseClient {
}
}
const conversation = await saveConvo(this.options?.req, fieldsToKeep, {
const conversation = await db.saveConvo(reqCtx, fieldsToKeep, {
context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveConvo',
unsetFields,
});
@ -1031,7 +824,35 @@ class BaseClient {
* @param {Partial<TMessage>} message
*/
async updateMessageInDatabase(message) {
await updateMessage(this.options.req, message);
await db.updateMessage(this.options?.req?.user?.id, message);
}
/** Extracts text from a summary block (handles both legacy `text` field and new `content` array format). */
static getSummaryText(summaryBlock) {
if (Array.isArray(summaryBlock.content)) {
return summaryBlock.content.map((b) => b.text ?? '').join('');
}
if (typeof summaryBlock.content === 'string') {
return summaryBlock.content;
}
return summaryBlock.text ?? '';
}
/** Finds the last summary content block in a message's content array (last-summary-wins). */
static findSummaryContentBlock(message) {
if (!Array.isArray(message?.content)) {
return null;
}
let lastSummary = null;
for (const part of message.content) {
if (
part?.type === ContentTypes.SUMMARY &&
BaseClient.getSummaryText(part).trim().length > 0
) {
lastSummary = part;
}
}
return lastSummary;
}
/**
@ -1088,20 +909,35 @@ class BaseClient {
break;
}
if (summary && message.summary) {
message.role = 'system';
message.text = message.summary;
let resolved = message;
let hasSummary = false;
if (summary) {
const summaryBlock = BaseClient.findSummaryContentBlock(message);
if (summaryBlock) {
const summaryText = BaseClient.getSummaryText(summaryBlock);
resolved = {
...message,
role: 'system',
content: [{ type: ContentTypes.TEXT, text: summaryText }],
tokenCount: summaryBlock.tokenCount,
};
hasSummary = true;
} else if (message.summary) {
resolved = {
...message,
role: 'system',
content: [{ type: ContentTypes.TEXT, text: message.summary }],
tokenCount: message.summaryTokenCount ?? message.tokenCount,
};
hasSummary = true;
}
}
if (summary && message.summaryTokenCount) {
message.tokenCount = message.summaryTokenCount;
}
const shouldMap = mapMethod != null && (mapCondition != null ? mapCondition(message) : true);
const processedMessage = shouldMap ? mapMethod(message) : message;
const shouldMap = mapMethod != null && (mapCondition != null ? mapCondition(resolved) : true);
const processedMessage = shouldMap ? mapMethod(resolved) : resolved;
orderedMessages.push(processedMessage);
if (summary && message.summary) {
if (hasSummary) {
break;
}
@ -1257,6 +1093,7 @@ class BaseClient {
provider: this.options.agent?.provider ?? this.options.endpoint,
endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
useResponsesApi: this.options.agent?.model_parameters?.useResponsesApi,
model: this.modelOptions?.model ?? this.model,
},
getStrategyFunctions,
);
@ -1329,6 +1166,16 @@ class BaseClient {
const provider = this.options.agent?.provider ?? this.options.endpoint;
const isBedrock = provider === EModelEndpoint.bedrock;
if (!this._mergedFileConfig && this.options.req?.config?.fileConfig) {
this._mergedFileConfig = mergeFileConfig(this.options.req.config.fileConfig);
const endpoint = this.options.agent?.endpoint ?? this.options.endpoint;
this._endpointFileConfig = getEndpointFileConfig({
fileConfig: this._mergedFileConfig,
endpoint,
endpointType: this.options.endpointType,
});
}
for (const file of attachments) {
/** @type {FileSources} */
const source = file.source ?? FileSources.local;
@ -1355,6 +1202,14 @@ class BaseClient {
} else if (file.type.startsWith('audio/')) {
categorizedAttachments.audios.push(file);
allFiles.push(file);
} else if (
file.type &&
this._mergedFileConfig &&
this._endpointFileConfig?.supportedMimeTypes &&
this._mergedFileConfig.checkType(file.type, this._endpointFileConfig.supportedMimeTypes)
) {
categorizedAttachments.documents.push(file);
allFiles.push(file);
}
}
@ -1431,7 +1286,7 @@ class BaseClient {
return message;
}
const files = await getFiles(
const files = await db.getFiles(
{
file_id: { $in: fileIds },
},

View file

@ -37,79 +37,4 @@ function smartTruncateText(text, maxLength = MAX_CHAR) {
return text;
}
/**
* @param {TMessage[]} _messages
* @param {number} maxContextTokens
* @param {function({role: string, content: TMessageContent[]}): number} getTokenCountForMessage
*
* @returns {{
* dbMessages: TMessage[],
* editedIndices: number[]
* }}
*/
function truncateToolCallOutputs(_messages, maxContextTokens, getTokenCountForMessage) {
const THRESHOLD_PERCENTAGE = 0.5;
const targetTokenLimit = maxContextTokens * THRESHOLD_PERCENTAGE;
let currentTokenCount = 3;
const messages = [..._messages];
const processedMessages = [];
let currentIndex = messages.length;
const editedIndices = new Set();
while (messages.length > 0) {
currentIndex--;
const message = messages.pop();
currentTokenCount += message.tokenCount;
if (currentTokenCount < targetTokenLimit) {
processedMessages.push(message);
continue;
}
if (!message.content || !Array.isArray(message.content)) {
processedMessages.push(message);
continue;
}
const toolCallIndices = message.content
.map((item, index) => (item.type === 'tool_call' ? index : -1))
.filter((index) => index !== -1)
.reverse();
if (toolCallIndices.length === 0) {
processedMessages.push(message);
continue;
}
const newContent = [...message.content];
// Truncate all tool outputs since we're over threshold
for (const index of toolCallIndices) {
const toolCall = newContent[index].tool_call;
if (!toolCall || !toolCall.output) {
continue;
}
editedIndices.add(currentIndex);
newContent[index] = {
...newContent[index],
tool_call: {
...toolCall,
output: '[OUTPUT_OMITTED_FOR_BREVITY]',
},
};
}
const truncatedMessage = {
...message,
content: newContent,
tokenCount: getTokenCountForMessage({ role: 'assistant', content: newContent }),
};
processedMessages.push(truncatedMessage);
}
return { dbMessages: processedMessages.reverse(), editedIndices: Array.from(editedIndices) };
}
module.exports = { truncateText, smartTruncateText, truncateToolCallOutputs };
module.exports = { truncateText, smartTruncateText };

View file

@ -38,7 +38,7 @@ jest.mock('~/models', () => ({
updateFileUsage: jest.fn(),
}));
const { getConvo, saveConvo } = require('~/models');
const { getConvo, saveConvo, saveMessage } = require('~/models');
jest.mock('@librechat/agents', () => {
const actual = jest.requireActual('@librechat/agents');
@ -355,7 +355,8 @@ describe('BaseClient', () => {
id: '3',
parentMessageId: '2',
role: 'system',
text: 'Summary for Message 3',
text: 'Message 3',
content: [{ type: 'text', text: 'Summary for Message 3' }],
summary: 'Summary for Message 3',
},
{ id: '4', parentMessageId: '3', text: 'Message 4' },
@ -380,7 +381,8 @@ describe('BaseClient', () => {
id: '4',
parentMessageId: '3',
role: 'system',
text: 'Summary for Message 4',
text: 'Message 4',
content: [{ type: 'text', text: 'Summary for Message 4' }],
summary: 'Summary for Message 4',
},
{ id: '5', parentMessageId: '4', text: 'Message 5' },
@ -405,12 +407,123 @@ describe('BaseClient', () => {
id: '4',
parentMessageId: '3',
role: 'system',
text: 'Summary for Message 4',
text: 'Message 4',
content: [{ type: 'text', text: 'Summary for Message 4' }],
summary: 'Summary for Message 4',
},
{ id: '5', parentMessageId: '4', text: 'Message 5' },
]);
});
it('should detect summary content block and use it over legacy fields (summary mode)', () => {
const messagesWithContentBlock = [
{ id: '3', parentMessageId: '2', text: 'Message 3' },
{
id: '2',
parentMessageId: '1',
text: 'Message 2',
content: [
{ type: 'text', text: 'Original text' },
{ type: 'summary', text: 'Content block summary', tokenCount: 42 },
],
},
{ id: '1', parentMessageId: null, text: 'Message 1' },
];
const result = TestClient.constructor.getMessagesForConversation({
messages: messagesWithContentBlock,
parentMessageId: '3',
summary: true,
});
expect(result).toHaveLength(2);
expect(result[0].role).toBe('system');
expect(result[0].content).toEqual([{ type: 'text', text: 'Content block summary' }]);
expect(result[0].tokenCount).toBe(42);
});
it('should prefer content block summary over legacy summary field', () => {
const messagesWithBoth = [
{ id: '2', parentMessageId: '1', text: 'Message 2' },
{
id: '1',
parentMessageId: null,
text: 'Message 1',
summary: 'Legacy summary',
summaryTokenCount: 10,
content: [{ type: 'summary', text: 'Content block summary', tokenCount: 20 }],
},
];
const result = TestClient.constructor.getMessagesForConversation({
messages: messagesWithBoth,
parentMessageId: '2',
summary: true,
});
expect(result).toHaveLength(2);
expect(result[0].content).toEqual([{ type: 'text', text: 'Content block summary' }]);
expect(result[0].tokenCount).toBe(20);
});
it('should fallback to legacy summary when no content block exists', () => {
const messagesWithLegacy = [
{ id: '2', parentMessageId: '1', text: 'Message 2' },
{
id: '1',
parentMessageId: null,
text: 'Message 1',
summary: 'Legacy summary only',
summaryTokenCount: 15,
},
];
const result = TestClient.constructor.getMessagesForConversation({
messages: messagesWithLegacy,
parentMessageId: '2',
summary: true,
});
expect(result).toHaveLength(2);
expect(result[0].content).toEqual([{ type: 'text', text: 'Legacy summary only' }]);
expect(result[0].tokenCount).toBe(15);
});
});
describe('findSummaryContentBlock', () => {
it('should find a summary block in the content array', () => {
const message = {
content: [
{ type: 'text', text: 'some text' },
{ type: 'summary', text: 'Summary of conversation', tokenCount: 50 },
],
};
const result = TestClient.constructor.findSummaryContentBlock(message);
expect(result).toBeTruthy();
expect(result.text).toBe('Summary of conversation');
expect(result.tokenCount).toBe(50);
});
it('should return null when no summary block exists', () => {
const message = {
content: [
{ type: 'text', text: 'some text' },
{ type: 'tool_call', tool_call: {} },
],
};
expect(TestClient.constructor.findSummaryContentBlock(message)).toBeNull();
});
it('should return null for string content', () => {
const message = { content: 'just a string' };
expect(TestClient.constructor.findSummaryContentBlock(message)).toBeNull();
});
it('should return null for missing content', () => {
expect(TestClient.constructor.findSummaryContentBlock({})).toBeNull();
expect(TestClient.constructor.findSummaryContentBlock(null)).toBeNull();
});
it('should skip summary blocks with no text', () => {
const message = {
content: [{ type: 'summary', tokenCount: 10 }],
};
expect(TestClient.constructor.findSummaryContentBlock(message)).toBeNull();
});
});
describe('sendMessage', () => {
@ -793,6 +906,52 @@ describe('BaseClient', () => {
);
});
test('saveMessageToDatabase returns early when this.options is null (client disposed)', async () => {
const savedOptions = TestClient.options;
TestClient.options = null;
saveMessage.mockClear();
const result = await TestClient.saveMessageToDatabase(
{ messageId: 'msg-1', conversationId: 'conv-1', isCreatedByUser: true, text: 'hi' },
{},
null,
);
expect(result).toEqual({});
expect(saveMessage).not.toHaveBeenCalled();
TestClient.options = savedOptions;
});
test('saveMessageToDatabase uses snapshot of options, immune to mid-await disposal', async () => {
const savedOptions = TestClient.options;
saveMessage.mockClear();
saveConvo.mockClear();
// Make db.saveMessage yield, simulating I/O suspension during which disposal occurs
saveMessage.mockImplementation(async (_reqCtx, msgData) => {
// Simulate disposeClient nullifying client.options while awaiting
TestClient.options = null;
return msgData;
});
saveConvo.mockResolvedValue({ conversationId: 'conv-1' });
const result = await TestClient.saveMessageToDatabase(
{ messageId: 'msg-1', conversationId: 'conv-1', isCreatedByUser: true, text: 'hi' },
{ endpoint: 'openAI' },
null,
);
// Should complete without TypeError, using the snapshotted options
expect(result).toHaveProperty('message');
expect(result).toHaveProperty('conversation');
expect(saveMessage).toHaveBeenCalled();
TestClient.options = savedOptions;
saveMessage.mockReset();
saveConvo.mockReset();
});
test('userMessagePromise is awaited before saving response message', async () => {
// Mock the saveMessageToDatabase method
TestClient.saveMessageToDatabase = jest.fn().mockImplementation(() => {

View file

@ -51,6 +51,10 @@ class DALLE3 extends Tool {
this.fileStrategy = fields.fileStrategy;
/** @type {boolean} */
this.isAgent = fields.isAgent;
if (this.isAgent) {
/** Ensures LangChain maps [content, artifact] tuple to ToolMessage fields instead of serializing it into content. */
this.responseFormat = 'content_and_artifact';
}
if (fields.processFileURL) {
/** @type {processFileURL} Necessary for output to contain all image metadata. */
this.processFileURL = fields.processFileURL.bind(this);

View file

@ -113,6 +113,10 @@ class FluxAPI extends Tool {
/** @type {boolean} **/
this.isAgent = fields.isAgent;
if (this.isAgent) {
/** Ensures LangChain maps [content, artifact] tuple to ToolMessage fields instead of serializing it into content. */
this.responseFormat = 'content_and_artifact';
}
this.returnMetadata = fields.returnMetadata ?? false;
if (fields.processFileURL) {
@ -524,10 +528,40 @@ class FluxAPI extends Tool {
return this.returnValue('No image data received from Flux API.');
}
// Try saving the image locally
const imageUrl = resultData.sample;
const imageName = `img-${uuidv4()}.png`;
if (this.isAgent) {
try {
const fetchOptions = {};
if (process.env.PROXY) {
fetchOptions.agent = new HttpsProxyAgent(process.env.PROXY);
}
const imageResponse = await fetch(imageUrl, fetchOptions);
const arrayBuffer = await imageResponse.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString('base64');
const content = [
{
type: ContentTypes.IMAGE_URL,
image_url: {
url: `data:image/png;base64,${base64}`,
},
},
];
const response = [
{
type: ContentTypes.TEXT,
text: displayMessage,
},
];
return [response, { content }];
} catch (error) {
logger.error('[FluxAPI] Error processing finetuned image for agent:', error);
return this.returnValue(`Failed to process the finetuned image. ${error.message}`);
}
}
try {
logger.debug('[FluxAPI] Saving finetuned image:', imageUrl);
const result = await this.processFileURL({
@ -541,12 +575,6 @@ class FluxAPI extends Tool {
logger.debug('[FluxAPI] Finetuned image saved to path:', result.filepath);
// Calculate cost based on endpoint
const endpointKey = endpoint.includes('ultra')
? 'FLUX_PRO_1_1_ULTRA_FINETUNED'
: 'FLUX_PRO_FINETUNED';
const cost = FluxAPI.PRICING[endpointKey] || 0;
// Return the result based on returnMetadata flag
this.result = this.returnMetadata ? result : this.wrapInMarkdown(result.filepath);
return this.returnValue(this.result);
} catch (error) {

View file

@ -13,8 +13,7 @@ const {
getTransactionsConfig,
} = require('@librechat/api');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { spendTokens } = require('~/models/spendTokens');
const { getFiles } = require('~/models/File');
const { spendTokens, getFiles } = require('~/models');
/**
* Configure proxy support for Google APIs

View file

@ -43,6 +43,10 @@ class StableDiffusionAPI extends Tool {
this.returnMetadata = fields.returnMetadata ?? false;
/** @type {boolean} */
this.isAgent = fields.isAgent;
if (this.isAgent) {
/** Ensures LangChain maps [content, artifact] tuple to ToolMessage fields instead of serializing it into content. */
this.responseFormat = 'content_and_artifact';
}
if (fields.uploadImageBuffer) {
/** @type {uploadImageBuffer} Necessary for output to contain all image metadata. */
this.uploadImageBuffer = fields.uploadImageBuffer.bind(this);
@ -115,7 +119,7 @@ class StableDiffusionAPI extends Tool {
generationResponse = await axios.post(`${url}/sdapi/v1/txt2img`, payload);
} catch (error) {
logger.error('[StableDiffusion] Error while generating image:', error);
return 'Error making API request.';
return this.returnValue('Error making API request.');
}
const image = generationResponse.data.images[0];

View file

@ -0,0 +1,294 @@
/**
* Regression tests for image tool agent mode verifies that invoke() returns
* a ToolMessage with base64 in artifact.content rather than serialized into content.
*
* Root cause: DALLE3/FluxAPI/StableDiffusion extend LangChain's Tool but did not
* set responseFormat = 'content_and_artifact'. LangChain's invoke() would then
* JSON.stringify the entire [content, artifact] tuple into ToolMessage.content,
* dumping base64 into token counting and causing context exhaustion.
*/
const axios = require('axios');
const OpenAI = require('openai');
const undici = require('undici');
const fetch = require('node-fetch');
const { ToolMessage } = require('@langchain/core/messages');
const { ContentTypes } = require('librechat-data-provider');
const StableDiffusionAPI = require('../StableDiffusion');
const FluxAPI = require('../FluxAPI');
const DALLE3 = require('../DALLE3');
jest.mock('axios');
jest.mock('openai');
jest.mock('node-fetch');
jest.mock('undici', () => ({
ProxyAgent: jest.fn(),
fetch: jest.fn(),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
}));
jest.mock('path', () => ({
resolve: jest.fn(),
join: jest.fn().mockReturnValue('/mock/path'),
relative: jest.fn().mockReturnValue('relative/path'),
extname: jest.fn().mockReturnValue('.png'),
}));
jest.mock('fs', () => ({
existsSync: jest.fn().mockReturnValue(true),
mkdirSync: jest.fn(),
promises: { writeFile: jest.fn(), readFile: jest.fn(), unlink: jest.fn() },
}));
const FAKE_BASE64 = 'aGVsbG8=';
const makeToolCall = (name, args) => ({
id: 'call_test_123',
name,
args,
type: 'tool_call',
});
describe('image tools - agent mode ToolMessage format', () => {
const ENV_KEYS = ['DALLE_API_KEY', 'FLUX_API_KEY', 'SD_WEBUI_URL', 'PROXY'];
let savedEnv = {};
beforeEach(() => {
jest.clearAllMocks();
for (const key of ENV_KEYS) {
savedEnv[key] = process.env[key];
}
process.env.DALLE_API_KEY = 'test-dalle-key';
process.env.FLUX_API_KEY = 'test-flux-key';
process.env.SD_WEBUI_URL = 'http://localhost:7860';
delete process.env.PROXY;
});
afterEach(() => {
for (const key of ENV_KEYS) {
if (savedEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = savedEnv[key];
}
}
savedEnv = {};
});
describe('DALLE3', () => {
beforeEach(() => {
OpenAI.mockImplementation(() => ({
images: {
generate: jest.fn().mockResolvedValue({
data: [{ url: 'https://example.com/image.png' }],
}),
},
}));
undici.fetch.mockResolvedValue({
arrayBuffer: () => Promise.resolve(Buffer.from(FAKE_BASE64, 'base64')),
});
});
it('sets responseFormat to content_and_artifact when isAgent is true', () => {
const dalle = new DALLE3({ isAgent: true });
expect(dalle.responseFormat).toBe('content_and_artifact');
});
it('does not set responseFormat when isAgent is false', () => {
const dalle = new DALLE3({ isAgent: false, processFileURL: jest.fn() });
expect(dalle.responseFormat).not.toBe('content_and_artifact');
});
it('invoke() returns ToolMessage with base64 in artifact, not serialized in content', async () => {
const dalle = new DALLE3({ isAgent: true });
const result = await dalle.invoke(
makeToolCall('dalle', {
prompt: 'a box',
quality: 'standard',
size: '1024x1024',
style: 'vivid',
}),
);
expect(result).toBeInstanceOf(ToolMessage);
const contentStr =
typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
expect(contentStr).not.toContain(FAKE_BASE64);
expect(result.artifact).toBeDefined();
const artifactContent = result.artifact?.content;
expect(Array.isArray(artifactContent)).toBe(true);
expect(artifactContent[0].type).toBe(ContentTypes.IMAGE_URL);
expect(artifactContent[0].image_url.url).toContain('base64');
});
it('invoke() returns ToolMessage with error string in content when API fails', async () => {
OpenAI.mockImplementation(() => ({
images: { generate: jest.fn().mockRejectedValue(new Error('API error')) },
}));
const dalle = new DALLE3({ isAgent: true });
const result = await dalle.invoke(
makeToolCall('dalle', {
prompt: 'a box',
quality: 'standard',
size: '1024x1024',
style: 'vivid',
}),
);
expect(result).toBeInstanceOf(ToolMessage);
const contentStr =
typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
expect(contentStr).toContain('Something went wrong');
expect(result.artifact).toBeDefined();
});
});
describe('FluxAPI', () => {
beforeEach(() => {
jest.useFakeTimers();
axios.post.mockResolvedValue({ data: { id: 'task-123' } });
axios.get.mockResolvedValue({
data: { status: 'Ready', result: { sample: 'https://example.com/image.png' } },
});
fetch.mockResolvedValue({
arrayBuffer: () => Promise.resolve(Buffer.from(FAKE_BASE64, 'base64')),
});
});
afterEach(() => {
jest.useRealTimers();
});
it('sets responseFormat to content_and_artifact when isAgent is true', () => {
const flux = new FluxAPI({ isAgent: true });
expect(flux.responseFormat).toBe('content_and_artifact');
});
it('does not set responseFormat when isAgent is false', () => {
const flux = new FluxAPI({ isAgent: false, processFileURL: jest.fn() });
expect(flux.responseFormat).not.toBe('content_and_artifact');
});
it('invoke() returns ToolMessage with base64 in artifact, not serialized in content', async () => {
const flux = new FluxAPI({ isAgent: true });
const invokePromise = flux.invoke(
makeToolCall('flux', { prompt: 'a box', endpoint: '/v1/flux-dev' }),
);
await jest.runAllTimersAsync();
const result = await invokePromise;
expect(result).toBeInstanceOf(ToolMessage);
const contentStr =
typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
expect(contentStr).not.toContain(FAKE_BASE64);
expect(result.artifact).toBeDefined();
const artifactContent = result.artifact?.content;
expect(Array.isArray(artifactContent)).toBe(true);
expect(artifactContent[0].type).toBe(ContentTypes.IMAGE_URL);
expect(artifactContent[0].image_url.url).toContain('base64');
});
it('invoke() returns ToolMessage with base64 in artifact for generate_finetuned action', async () => {
const flux = new FluxAPI({ isAgent: true });
const invokePromise = flux.invoke(
makeToolCall('flux', {
action: 'generate_finetuned',
prompt: 'a box',
finetune_id: 'ft-abc123',
endpoint: '/v1/flux-pro-finetuned',
}),
);
await jest.runAllTimersAsync();
const result = await invokePromise;
expect(result).toBeInstanceOf(ToolMessage);
const contentStr =
typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
expect(contentStr).not.toContain(FAKE_BASE64);
expect(result.artifact).toBeDefined();
const artifactContent = result.artifact?.content;
expect(Array.isArray(artifactContent)).toBe(true);
expect(artifactContent[0].type).toBe(ContentTypes.IMAGE_URL);
expect(artifactContent[0].image_url.url).toContain('base64');
});
it('invoke() returns ToolMessage with error string in content when task submission fails', async () => {
axios.post.mockRejectedValue(new Error('Network error'));
const flux = new FluxAPI({ isAgent: true });
const invokePromise = flux.invoke(
makeToolCall('flux', { prompt: 'a box', endpoint: '/v1/flux-dev' }),
);
await jest.runAllTimersAsync();
const result = await invokePromise;
expect(result).toBeInstanceOf(ToolMessage);
const contentStr =
typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
expect(contentStr).toContain('Something went wrong');
expect(result.artifact).toBeDefined();
});
});
describe('StableDiffusion', () => {
beforeEach(() => {
axios.post.mockResolvedValue({
data: {
images: [FAKE_BASE64],
info: JSON.stringify({ height: 1024, width: 1024, seed: 42, infotexts: [] }),
},
});
});
it('sets responseFormat to content_and_artifact when isAgent is true', () => {
const sd = new StableDiffusionAPI({ isAgent: true, override: true });
expect(sd.responseFormat).toBe('content_and_artifact');
});
it('does not set responseFormat when isAgent is false', () => {
const sd = new StableDiffusionAPI({
isAgent: false,
override: true,
uploadImageBuffer: jest.fn(),
});
expect(sd.responseFormat).not.toBe('content_and_artifact');
});
it('invoke() returns ToolMessage with base64 in artifact, not serialized in content', async () => {
const sd = new StableDiffusionAPI({ isAgent: true, override: true, userId: 'user-1' });
const result = await sd.invoke(
makeToolCall('stable-diffusion', { prompt: 'a box', negative_prompt: '' }),
);
expect(result).toBeInstanceOf(ToolMessage);
const contentStr =
typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
expect(contentStr).not.toContain(FAKE_BASE64);
expect(result.artifact).toBeDefined();
const artifactContent = result.artifact?.content;
expect(Array.isArray(artifactContent)).toBe(true);
expect(artifactContent[0].type).toBe(ContentTypes.IMAGE_URL);
expect(artifactContent[0].image_url.url).toContain('base64');
});
it('invoke() returns ToolMessage with error string in content when API fails', async () => {
axios.post.mockRejectedValue(new Error('Connection refused'));
const sd = new StableDiffusionAPI({ isAgent: true, override: true, userId: 'user-1' });
const result = await sd.invoke(
makeToolCall('stable-diffusion', { prompt: 'a box', negative_prompt: '' }),
);
expect(result).toBeInstanceOf(ToolMessage);
const contentStr =
typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
expect(contentStr).toContain('Error making API request');
});
});
});

View file

@ -14,7 +14,6 @@ const {
buildImageToolContext,
buildWebSearchContext,
} = require('@librechat/api');
const { getMCPServersRegistry } = require('~/config');
const {
Tools,
Constants,
@ -39,13 +38,14 @@ const {
createGeminiImageTool,
createOpenAIImageTools,
} = require('../');
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
const { createMCPTool, createMCPTools, resolveConfigServers } = require('~/server/services/MCP');
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { createMCPTool, createMCPTools } = require('~/server/services/MCP');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { getMCPServerTools } = require('~/server/services/Config');
const { getRoleByName } = require('~/models/Role');
const { getMCPServersRegistry } = require('~/config');
const { getRoleByName } = require('~/models');
/**
* Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values.
@ -256,6 +256,12 @@ const loadTools = async ({
const toolContextMap = {};
const requestedMCPTools = {};
/** Resolve config-source servers for the current user/tenant context */
let configServers;
if (tools.some((tool) => tool && mcpToolPattern.test(tool))) {
configServers = await resolveConfigServers(options.req);
}
for (const tool of tools) {
if (tool === Tools.execute_code) {
requestedTools[tool] = async () => {
@ -341,7 +347,7 @@ const loadTools = async ({
continue;
}
const serverConfig = serverName
? await getMCPServersRegistry().getServerConfig(serverName, user)
? await getMCPServersRegistry().getServerConfig(serverName, user, configServers)
: null;
if (!serverConfig) {
logger.warn(
@ -419,6 +425,7 @@ const loadTools = async ({
let index = -1;
const failedMCPServers = new Set();
const safeUser = createSafeUser(options.req?.user);
for (const [serverName, toolConfigs] of Object.entries(requestedMCPTools)) {
index++;
/** @type {LCAvailableTools} */
@ -433,6 +440,7 @@ const loadTools = async ({
signal,
user: safeUser,
userMCPAuthMap,
configServers,
res: options.res,
streamId: options.req?._resumableStreamId || null,
model: agent?.model ?? model,

View file

@ -1,8 +1,7 @@
const { logger } = require('@librechat/data-schemas');
const { isEnabled, math } = require('@librechat/api');
const { ViolationTypes } = require('librechat-data-provider');
const { isEnabled, math, removePorts } = require('@librechat/api');
const { deleteAllUserSessions } = require('~/models');
const { removePorts } = require('~/server/utils');
const getLogStores = require('./getLogStores');
const { BAN_VIOLATIONS, BAN_INTERVAL } = process.env ?? {};

View file

@ -1,8 +1,13 @@
const mongoose = require('mongoose');
const { createModels } = require('@librechat/data-schemas');
const { connectDb } = require('./connect');
const indexSync = require('./indexSync');
// createModels MUST run before requiring indexSync.
// indexSync.js captures mongoose.models.Message and mongoose.models.Conversation
// at module load time. If those models are not registered first, all MeiliSearch
// sync operations will silently fail on every startup.
createModels(mongoose);
const indexSync = require('./indexSync');
module.exports = { connectDb, indexSync };

26
api/db/index.spec.js Normal file
View file

@ -0,0 +1,26 @@
describe('api/db/index.js', () => {
test('createModels is called before indexSync is loaded', () => {
jest.resetModules();
const callOrder = [];
jest.mock('@librechat/data-schemas', () => ({
createModels: jest.fn((m) => {
callOrder.push('createModels');
m.models.Message = { name: 'Message' };
m.models.Conversation = { name: 'Conversation' };
}),
}));
jest.mock('./indexSync', () => {
callOrder.push('indexSync');
return jest.fn();
});
jest.mock('./connect', () => ({ connectDb: jest.fn() }));
require('./index');
expect(callOrder).toEqual(['createModels', 'indexSync']);
});
});

View file

@ -6,9 +6,6 @@ const { isEnabled, FlowStateManager } = require('@librechat/api');
const { getLogStores } = require('~/cache');
const { batchResetMeiliFlags } = require('./utils');
const Conversation = mongoose.models.Conversation;
const Message = mongoose.models.Message;
const searchEnabled = isEnabled(process.env.SEARCH);
const indexingDisabled = isEnabled(process.env.MEILI_NO_SYNC);
let currentTimeout = null;
@ -200,6 +197,14 @@ async function performSync(flowManager, flowId, flowType) {
return { messagesSync: false, convosSync: false };
}
const Message = mongoose.models.Message;
const Conversation = mongoose.models.Conversation;
if (!Message || !Conversation) {
throw new Error(
'[indexSync] Models not registered. Ensure createModels() has been called before indexSync.',
);
}
const client = MeiliSearchClient.getInstance();
const { status } = await client.health();
@ -349,6 +354,13 @@ async function indexSync() {
logger.debug('[indexSync] Creating indices...');
currentTimeout = setTimeout(async () => {
try {
const Message = mongoose.models.Message;
const Conversation = mongoose.models.Conversation;
if (!Message || !Conversation) {
throw new Error(
'[indexSync] Models not registered. Ensure createModels() has been called before indexSync.',
);
}
await Message.syncWithMeili();
await Conversation.syncWithMeili();
} catch (err) {

View file

@ -1,73 +0,0 @@
const { Action } = require('~/db/models');
/**
* Update an action with new data without overwriting existing properties,
* or create a new action if it doesn't exist.
*
* @param {{ action_id: string, agent_id?: string, assistant_id?: string, user?: string }} searchParams
* @param {Object} updateData - An object containing the properties to update.
* @returns {Promise<Action>} The updated or newly created action document as a plain object.
*/
const updateAction = async (searchParams, updateData) => {
const options = { new: true, upsert: true };
return await Action.findOneAndUpdate(searchParams, updateData, options).lean();
};
/**
* Retrieves all actions that match the given search parameters.
*
* @param {Object} searchParams - The search parameters to find matching actions.
* @param {boolean} includeSensitive - Flag to include sensitive data in the metadata.
* @returns {Promise<Array<Action>>} A promise that resolves to an array of action documents as plain objects.
*/
const getActions = async (searchParams, includeSensitive = false) => {
const actions = await Action.find(searchParams).lean();
if (!includeSensitive) {
for (let i = 0; i < actions.length; i++) {
const metadata = actions[i].metadata;
if (!metadata) {
continue;
}
const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
for (let field of sensitiveFields) {
if (metadata[field]) {
delete metadata[field];
}
}
}
}
return actions;
};
/**
* Deletes an action by params.
*
* @param {{ action_id: string, agent_id?: string, assistant_id?: string, user?: string }} searchParams
* @returns {Promise<Action|null>} The deleted action document as a plain object, or null if no match.
*/
const deleteAction = async (searchParams) => {
return await Action.findOneAndDelete(searchParams).lean();
};
/**
* Deletes actions by params.
*
* @param {Object} searchParams - The search parameters to find the actions to delete.
* @param {string} searchParams.action_id - The ID of the action(s) to delete.
* @param {string} searchParams.user - The user ID of the action's author.
* @returns {Promise<Number>} A promise that resolves to the number of deleted action documents.
*/
const deleteActions = async (searchParams) => {
const result = await Action.deleteMany(searchParams);
return result.deletedCount;
};
module.exports = {
getActions,
updateAction,
deleteAction,
deleteActions,
};

View file

@ -1,250 +0,0 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { actionSchema } = require('@librechat/data-schemas');
const { updateAction, getActions, deleteAction } = require('./Action');
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
if (!mongoose.models.Action) {
mongoose.model('Action', actionSchema);
}
await mongoose.connect(mongoUri);
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.models.Action.deleteMany({});
});
const userId = new mongoose.Types.ObjectId();
describe('Action ownership scoping', () => {
describe('updateAction', () => {
it('updates when action_id and agent_id both match', async () => {
await mongoose.models.Action.create({
user: userId,
action_id: 'act_1',
agent_id: 'agent_A',
metadata: { domain: 'example.com' },
});
const result = await updateAction(
{ action_id: 'act_1', agent_id: 'agent_A' },
{ metadata: { domain: 'updated.com' } },
);
expect(result).not.toBeNull();
expect(result.metadata.domain).toBe('updated.com');
expect(result.agent_id).toBe('agent_A');
});
it('does not update when agent_id does not match (creates a new doc via upsert)', async () => {
await mongoose.models.Action.create({
user: userId,
action_id: 'act_1',
agent_id: 'agent_B',
metadata: { domain: 'victim.com', api_key: 'secret' },
});
const result = await updateAction(
{ action_id: 'act_1', agent_id: 'agent_A' },
{ user: userId, metadata: { domain: 'attacker.com' } },
);
expect(result.metadata.domain).toBe('attacker.com');
const original = await mongoose.models.Action.findOne({
action_id: 'act_1',
agent_id: 'agent_B',
}).lean();
expect(original).not.toBeNull();
expect(original.metadata.domain).toBe('victim.com');
expect(original.metadata.api_key).toBe('secret');
});
it('updates when action_id and assistant_id both match', async () => {
await mongoose.models.Action.create({
user: userId,
action_id: 'act_2',
assistant_id: 'asst_X',
metadata: { domain: 'example.com' },
});
const result = await updateAction(
{ action_id: 'act_2', assistant_id: 'asst_X' },
{ metadata: { domain: 'updated.com' } },
);
expect(result).not.toBeNull();
expect(result.metadata.domain).toBe('updated.com');
});
it('does not overwrite when assistant_id does not match', async () => {
await mongoose.models.Action.create({
user: userId,
action_id: 'act_2',
assistant_id: 'asst_victim',
metadata: { domain: 'victim.com', api_key: 'secret' },
});
await updateAction(
{ action_id: 'act_2', assistant_id: 'asst_attacker' },
{ user: userId, metadata: { domain: 'attacker.com' } },
);
const original = await mongoose.models.Action.findOne({
action_id: 'act_2',
assistant_id: 'asst_victim',
}).lean();
expect(original).not.toBeNull();
expect(original.metadata.domain).toBe('victim.com');
expect(original.metadata.api_key).toBe('secret');
});
});
describe('deleteAction', () => {
it('deletes when action_id and agent_id both match', async () => {
await mongoose.models.Action.create({
user: userId,
action_id: 'act_del',
agent_id: 'agent_A',
metadata: { domain: 'example.com' },
});
const result = await deleteAction({ action_id: 'act_del', agent_id: 'agent_A' });
expect(result).not.toBeNull();
expect(result.action_id).toBe('act_del');
const remaining = await mongoose.models.Action.countDocuments();
expect(remaining).toBe(0);
});
it('returns null and preserves the document when agent_id does not match', async () => {
await mongoose.models.Action.create({
user: userId,
action_id: 'act_del',
agent_id: 'agent_B',
metadata: { domain: 'victim.com' },
});
const result = await deleteAction({ action_id: 'act_del', agent_id: 'agent_A' });
expect(result).toBeNull();
const remaining = await mongoose.models.Action.countDocuments();
expect(remaining).toBe(1);
});
it('deletes when action_id and assistant_id both match', async () => {
await mongoose.models.Action.create({
user: userId,
action_id: 'act_del_asst',
assistant_id: 'asst_X',
metadata: { domain: 'example.com' },
});
const result = await deleteAction({ action_id: 'act_del_asst', assistant_id: 'asst_X' });
expect(result).not.toBeNull();
const remaining = await mongoose.models.Action.countDocuments();
expect(remaining).toBe(0);
});
it('returns null and preserves the document when assistant_id does not match', async () => {
await mongoose.models.Action.create({
user: userId,
action_id: 'act_del_asst',
assistant_id: 'asst_victim',
metadata: { domain: 'victim.com' },
});
const result = await deleteAction({
action_id: 'act_del_asst',
assistant_id: 'asst_attacker',
});
expect(result).toBeNull();
const remaining = await mongoose.models.Action.countDocuments();
expect(remaining).toBe(1);
});
});
describe('getActions (unscoped baseline)', () => {
it('returns actions by action_id regardless of agent_id', async () => {
await mongoose.models.Action.create({
user: userId,
action_id: 'act_shared',
agent_id: 'agent_B',
metadata: { domain: 'example.com' },
});
const results = await getActions({ action_id: 'act_shared' }, true);
expect(results).toHaveLength(1);
expect(results[0].agent_id).toBe('agent_B');
});
it('returns actions scoped by agent_id when provided', async () => {
await mongoose.models.Action.create({
user: userId,
action_id: 'act_scoped',
agent_id: 'agent_A',
metadata: { domain: 'a.com' },
});
await mongoose.models.Action.create({
user: userId,
action_id: 'act_other',
agent_id: 'agent_B',
metadata: { domain: 'b.com' },
});
const results = await getActions({ agent_id: 'agent_A' });
expect(results).toHaveLength(1);
expect(results[0].action_id).toBe('act_scoped');
});
});
describe('cross-type protection', () => {
it('updateAction with agent_id filter does not overwrite assistant-owned action', async () => {
await mongoose.models.Action.create({
user: userId,
action_id: 'act_cross',
assistant_id: 'asst_victim',
metadata: { domain: 'victim.com', api_key: 'secret' },
});
await updateAction(
{ action_id: 'act_cross', agent_id: 'agent_attacker' },
{ user: userId, metadata: { domain: 'evil.com' } },
);
const original = await mongoose.models.Action.findOne({
action_id: 'act_cross',
assistant_id: 'asst_victim',
}).lean();
expect(original).not.toBeNull();
expect(original.metadata.domain).toBe('victim.com');
expect(original.metadata.api_key).toBe('secret');
});
it('deleteAction with agent_id filter does not delete assistant-owned action', async () => {
await mongoose.models.Action.create({
user: userId,
action_id: 'act_cross_del',
assistant_id: 'asst_victim',
metadata: { domain: 'victim.com' },
});
const result = await deleteAction({ action_id: 'act_cross_del', agent_id: 'agent_attacker' });
expect(result).toBeNull();
const remaining = await mongoose.models.Action.countDocuments();
expect(remaining).toBe(1);
});
});
});

View file

@ -1,931 +0,0 @@
const mongoose = require('mongoose');
const crypto = require('node:crypto');
const { logger } = require('@librechat/data-schemas');
const { getCustomEndpointConfig } = require('@librechat/api');
const {
Tools,
SystemRoles,
ResourceType,
actionDelimiter,
isAgentsEndpoint,
isEphemeralAgentId,
encodeEphemeralAgentId,
} = require('librechat-data-provider');
const { mcp_all, mcp_delimiter } = require('librechat-data-provider').Constants;
const {
removeAgentFromAllProjects,
removeAgentIdsFromProject,
addAgentIdsToProject,
} = require('./Project');
const { removeAllPermissions } = require('~/server/services/PermissionService');
const { getMCPServerTools } = require('~/server/services/Config');
const { Agent, AclEntry, User } = require('~/db/models');
const { getActions } = require('./Action');
/**
* Extracts unique MCP server names from tools array
* Tools format: "toolName_mcp_serverName" or "sys__server__sys_mcp_serverName"
* @param {string[]} tools - Array of tool identifiers
* @returns {string[]} Array of unique MCP server names
*/
const extractMCPServerNames = (tools) => {
if (!tools || !Array.isArray(tools)) {
return [];
}
const serverNames = new Set();
for (const tool of tools) {
if (!tool || !tool.includes(mcp_delimiter)) {
continue;
}
const parts = tool.split(mcp_delimiter);
if (parts.length >= 2) {
serverNames.add(parts[parts.length - 1]);
}
}
return Array.from(serverNames);
};
/**
* Create an agent with the provided data.
* @param {Object} agentData - The agent data to create.
* @returns {Promise<Agent>} The created agent document as a plain object.
* @throws {Error} If the agent creation fails.
*/
const createAgent = async (agentData) => {
const { author: _author, ...versionData } = agentData;
const timestamp = new Date();
const initialAgentData = {
...agentData,
versions: [
{
...versionData,
createdAt: timestamp,
updatedAt: timestamp,
},
],
category: agentData.category || 'general',
mcpServerNames: extractMCPServerNames(agentData.tools),
};
return (await Agent.create(initialAgentData)).toObject();
};
/**
* Get an agent document based on the provided ID.
*
* @param {Object} searchParameter - The search parameters to find the agent to update.
* @param {string} searchParameter.id - The ID of the agent to update.
* @param {string} searchParameter.author - The user ID of the agent's author.
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
*/
const getAgent = async (searchParameter) => await Agent.findOne(searchParameter).lean();
/**
* Get multiple agent documents based on the provided search parameters.
*
* @param {Object} searchParameter - The search parameters to find agents.
* @returns {Promise<Agent[]>} Array of agent documents as plain objects.
*/
const getAgents = async (searchParameter) => await Agent.find(searchParameter).lean();
/**
* Load an agent based on the provided ID
*
* @param {Object} params
* @param {ServerRequest} params.req
* @param {string} params.spec
* @param {string} params.agent_id
* @param {string} params.endpoint
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
*/
const loadEphemeralAgent = async ({ req, spec, endpoint, model_parameters: _m }) => {
const { model, ...model_parameters } = _m;
const modelSpecs = req.config?.modelSpecs?.list;
/** @type {TModelSpec | null} */
let modelSpec = null;
if (spec != null && spec !== '') {
modelSpec = modelSpecs?.find((s) => s.name === spec) || null;
}
/** @type {TEphemeralAgent | null} */
const ephemeralAgent = req.body.ephemeralAgent;
const mcpServers = new Set(ephemeralAgent?.mcp);
const userId = req.user?.id; // note: userId cannot be undefined at runtime
if (modelSpec?.mcpServers) {
for (const mcpServer of modelSpec.mcpServers) {
mcpServers.add(mcpServer);
}
}
/** @type {string[]} */
const tools = [];
if (ephemeralAgent?.execute_code === true || modelSpec?.executeCode === true) {
tools.push(Tools.execute_code);
}
if (ephemeralAgent?.file_search === true || modelSpec?.fileSearch === true) {
tools.push(Tools.file_search);
}
if (ephemeralAgent?.web_search === true || modelSpec?.webSearch === true) {
tools.push(Tools.web_search);
}
const addedServers = new Set();
if (mcpServers.size > 0) {
for (const mcpServer of mcpServers) {
if (addedServers.has(mcpServer)) {
continue;
}
const serverTools = await getMCPServerTools(userId, mcpServer);
if (!serverTools) {
tools.push(`${mcp_all}${mcp_delimiter}${mcpServer}`);
addedServers.add(mcpServer);
continue;
}
tools.push(...Object.keys(serverTools));
addedServers.add(mcpServer);
}
}
const instructions = req.body.promptPrefix;
// Get endpoint config for modelDisplayLabel fallback
const appConfig = req.config;
let endpointConfig = appConfig?.endpoints?.[endpoint];
if (!isAgentsEndpoint(endpoint) && !endpointConfig) {
try {
endpointConfig = getCustomEndpointConfig({ endpoint, appConfig });
} catch (err) {
logger.error('[loadEphemeralAgent] Error getting custom endpoint config', err);
}
}
// For ephemeral agents, use modelLabel if provided, then model spec's label,
// then modelDisplayLabel from endpoint config, otherwise empty string to show model name
const sender =
model_parameters?.modelLabel ?? modelSpec?.label ?? endpointConfig?.modelDisplayLabel ?? '';
// Encode ephemeral agent ID with endpoint, model, and computed sender for display
const ephemeralId = encodeEphemeralAgentId({ endpoint, model, sender });
const result = {
id: ephemeralId,
instructions,
provider: endpoint,
model_parameters,
model,
tools,
};
if (ephemeralAgent?.artifacts != null && ephemeralAgent.artifacts) {
result.artifacts = ephemeralAgent.artifacts;
}
return result;
};
/**
* Load an agent based on the provided ID
*
* @param {Object} params
* @param {ServerRequest} params.req
* @param {string} params.spec
* @param {string} params.agent_id
* @param {string} params.endpoint
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
*/
const loadAgent = async ({ req, spec, agent_id, endpoint, model_parameters }) => {
if (!agent_id) {
return null;
}
if (isEphemeralAgentId(agent_id)) {
return await loadEphemeralAgent({ req, spec, endpoint, model_parameters });
}
const agent = await getAgent({
id: agent_id,
});
if (!agent) {
return null;
}
agent.version = agent.versions ? agent.versions.length : 0;
return agent;
};
/**
* Check if a version already exists in the versions array, excluding timestamp and author fields
* @param {Object} updateData - The update data to compare
* @param {Object} currentData - The current agent data
* @param {Array} versions - The existing versions array
* @param {string} [actionsHash] - Hash of current action metadata
* @returns {Object|null} - The matching version if found, null otherwise
*/
const isDuplicateVersion = (updateData, currentData, versions, actionsHash = null) => {
if (!versions || versions.length === 0) {
return null;
}
const excludeFields = [
'_id',
'id',
'createdAt',
'updatedAt',
'author',
'updatedBy',
'created_at',
'updated_at',
'__v',
'versions',
'actionsHash', // Exclude actionsHash from direct comparison
];
const { $push: _$push, $pull: _$pull, $addToSet: _$addToSet, ...directUpdates } = updateData;
if (Object.keys(directUpdates).length === 0 && !actionsHash) {
return null;
}
const wouldBeVersion = { ...currentData, ...directUpdates };
const lastVersion = versions[versions.length - 1];
if (actionsHash && lastVersion.actionsHash !== actionsHash) {
return null;
}
const allFields = new Set([...Object.keys(wouldBeVersion), ...Object.keys(lastVersion)]);
const importantFields = Array.from(allFields).filter((field) => !excludeFields.includes(field));
let isMatch = true;
for (const field of importantFields) {
const wouldBeValue = wouldBeVersion[field];
const lastVersionValue = lastVersion[field];
// Skip if both are undefined/null
if (!wouldBeValue && !lastVersionValue) {
continue;
}
// Handle arrays
if (Array.isArray(wouldBeValue) || Array.isArray(lastVersionValue)) {
// Normalize: treat undefined/null as empty array for comparison
let wouldBeArr;
if (Array.isArray(wouldBeValue)) {
wouldBeArr = wouldBeValue;
} else if (wouldBeValue == null) {
wouldBeArr = [];
} else {
wouldBeArr = [wouldBeValue];
}
let lastVersionArr;
if (Array.isArray(lastVersionValue)) {
lastVersionArr = lastVersionValue;
} else if (lastVersionValue == null) {
lastVersionArr = [];
} else {
lastVersionArr = [lastVersionValue];
}
if (wouldBeArr.length !== lastVersionArr.length) {
isMatch = false;
break;
}
// Special handling for projectIds (MongoDB ObjectIds)
if (field === 'projectIds') {
const wouldBeIds = wouldBeArr.map((id) => id.toString()).sort();
const versionIds = lastVersionArr.map((id) => id.toString()).sort();
if (!wouldBeIds.every((id, i) => id === versionIds[i])) {
isMatch = false;
break;
}
}
// Handle arrays of objects
else if (
wouldBeArr.length > 0 &&
typeof wouldBeArr[0] === 'object' &&
wouldBeArr[0] !== null
) {
const sortedWouldBe = [...wouldBeArr].map((item) => JSON.stringify(item)).sort();
const sortedVersion = [...lastVersionArr].map((item) => JSON.stringify(item)).sort();
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
isMatch = false;
break;
}
} else {
const sortedWouldBe = [...wouldBeArr].sort();
const sortedVersion = [...lastVersionArr].sort();
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
isMatch = false;
break;
}
}
}
// Handle objects
else if (typeof wouldBeValue === 'object' && wouldBeValue !== null) {
const lastVersionObj =
typeof lastVersionValue === 'object' && lastVersionValue !== null ? lastVersionValue : {};
// For empty objects, normalize the comparison
const wouldBeKeys = Object.keys(wouldBeValue);
const lastVersionKeys = Object.keys(lastVersionObj);
// If both are empty objects, they're equal
if (wouldBeKeys.length === 0 && lastVersionKeys.length === 0) {
continue;
}
// Otherwise do a deep comparison
if (JSON.stringify(wouldBeValue) !== JSON.stringify(lastVersionObj)) {
isMatch = false;
break;
}
}
// Handle primitive values
else {
// For primitives, handle the case where one is undefined and the other is a default value
if (wouldBeValue !== lastVersionValue) {
// Special handling for boolean false vs undefined
if (
typeof wouldBeValue === 'boolean' &&
wouldBeValue === false &&
lastVersionValue === undefined
) {
continue;
}
// Special handling for empty string vs undefined
if (
typeof wouldBeValue === 'string' &&
wouldBeValue === '' &&
lastVersionValue === undefined
) {
continue;
}
isMatch = false;
break;
}
}
}
return isMatch ? lastVersion : null;
};
/**
* Update an agent with new data without overwriting existing
* properties, or create a new agent if it doesn't exist.
* When an agent is updated, a copy of the current state will be saved to the versions array.
*
* @param {Object} searchParameter - The search parameters to find the agent to update.
* @param {string} searchParameter.id - The ID of the agent to update.
* @param {string} [searchParameter.author] - The user ID of the agent's author.
* @param {Object} updateData - An object containing the properties to update.
* @param {Object} [options] - Optional configuration object.
* @param {string} [options.updatingUserId] - The ID of the user performing the update (used for tracking non-author updates).
* @param {boolean} [options.forceVersion] - Force creation of a new version even if no fields changed.
* @param {boolean} [options.skipVersioning] - Skip version creation entirely (useful for isolated operations like sharing).
* @returns {Promise<Agent>} The updated or newly created agent document as a plain object.
* @throws {Error} If the update would create a duplicate version
*/
const updateAgent = async (searchParameter, updateData, options = {}) => {
const { updatingUserId = null, forceVersion = false, skipVersioning = false } = options;
const mongoOptions = { new: true, upsert: false };
const currentAgent = await Agent.findOne(searchParameter);
if (currentAgent) {
const {
__v,
_id,
id: __id,
versions,
author: _author,
...versionData
} = currentAgent.toObject();
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
// Sync mcpServerNames when tools are updated
if (directUpdates.tools !== undefined) {
const mcpServerNames = extractMCPServerNames(directUpdates.tools);
directUpdates.mcpServerNames = mcpServerNames;
updateData.mcpServerNames = mcpServerNames; // Also update the original updateData
}
let actionsHash = null;
// Generate actions hash if agent has actions
if (currentAgent.actions && currentAgent.actions.length > 0) {
// Extract action IDs from the format "domain_action_id"
const actionIds = currentAgent.actions
.map((action) => {
const parts = action.split(actionDelimiter);
return parts[1]; // Get just the action ID part
})
.filter(Boolean);
if (actionIds.length > 0) {
try {
const actions = await getActions(
{
action_id: { $in: actionIds },
},
true,
); // Include sensitive data for hash
actionsHash = await generateActionMetadataHash(currentAgent.actions, actions);
} catch (error) {
logger.error('Error fetching actions for hash generation:', error);
}
}
}
const shouldCreateVersion =
!skipVersioning &&
(forceVersion || Object.keys(directUpdates).length > 0 || $push || $pull || $addToSet);
if (shouldCreateVersion) {
const duplicateVersion = isDuplicateVersion(updateData, versionData, versions, actionsHash);
if (duplicateVersion && !forceVersion) {
// No changes detected, return the current agent without creating a new version
const agentObj = currentAgent.toObject();
agentObj.version = versions.length;
return agentObj;
}
}
const versionEntry = {
...versionData,
...directUpdates,
updatedAt: new Date(),
};
// Include actions hash in version if available
if (actionsHash) {
versionEntry.actionsHash = actionsHash;
}
// Always store updatedBy field to track who made the change
if (updatingUserId) {
versionEntry.updatedBy = new mongoose.Types.ObjectId(updatingUserId);
}
if (shouldCreateVersion) {
updateData.$push = {
...($push || {}),
versions: versionEntry,
};
}
}
return Agent.findOneAndUpdate(searchParameter, updateData, mongoOptions).lean();
};
/**
* Modifies an agent with the resource file id.
* @param {object} params
* @param {ServerRequest} params.req
* @param {string} params.agent_id
* @param {string} params.tool_resource
* @param {string} params.file_id
* @returns {Promise<Agent>} The updated agent.
*/
const addAgentResourceFile = async ({ req, agent_id, tool_resource, file_id }) => {
const searchParameter = { id: agent_id };
let agent = await getAgent(searchParameter);
if (!agent) {
throw new Error('Agent not found for adding resource file');
}
const fileIdsPath = `tool_resources.${tool_resource}.file_ids`;
await Agent.updateOne(
{
id: agent_id,
[`${fileIdsPath}`]: { $exists: false },
},
{
$set: {
[`${fileIdsPath}`]: [],
},
},
);
const updateData = {
$addToSet: {
tools: tool_resource,
[fileIdsPath]: file_id,
},
};
const updatedAgent = await updateAgent(searchParameter, updateData, {
updatingUserId: req?.user?.id,
});
if (updatedAgent) {
return updatedAgent;
} else {
throw new Error('Agent not found for adding resource file');
}
};
/**
* Removes multiple resource files from an agent using atomic operations.
* @param {object} params
* @param {string} params.agent_id
* @param {Array<{tool_resource: string, file_id: string}>} params.files
* @returns {Promise<Agent>} The updated agent.
* @throws {Error} If the agent is not found or update fails.
*/
const removeAgentResourceFiles = async ({ agent_id, files }) => {
const searchParameter = { id: agent_id };
// Group files to remove by resource
const filesByResource = files.reduce((acc, { tool_resource, file_id }) => {
if (!acc[tool_resource]) {
acc[tool_resource] = [];
}
acc[tool_resource].push(file_id);
return acc;
}, {});
// Step 1: Atomically remove file IDs using $pull
const pullOps = {};
const resourcesToCheck = new Set();
for (const [resource, fileIds] of Object.entries(filesByResource)) {
const fileIdsPath = `tool_resources.${resource}.file_ids`;
pullOps[fileIdsPath] = { $in: fileIds };
resourcesToCheck.add(resource);
}
const updatePullData = { $pull: pullOps };
const agentAfterPull = await Agent.findOneAndUpdate(searchParameter, updatePullData, {
new: true,
}).lean();
if (!agentAfterPull) {
// Agent might have been deleted concurrently, or never existed.
// Check if it existed before trying to throw.
const agentExists = await getAgent(searchParameter);
if (!agentExists) {
throw new Error('Agent not found for removing resource files');
}
// If it existed but findOneAndUpdate returned null, something else went wrong.
throw new Error('Failed to update agent during file removal (pull step)');
}
// Return the agent state directly after the $pull operation.
// Skipping the $unset step for now to simplify and test core $pull atomicity.
// Empty arrays might remain, but the removal itself should be correct.
return agentAfterPull;
};
/**
* Deletes an agent based on the provided ID.
*
* @param {Object} searchParameter - The search parameters to find the agent to delete.
* @param {string} searchParameter.id - The ID of the agent to delete.
* @param {string} [searchParameter.author] - The user ID of the agent's author.
* @returns {Promise<void>} Resolves when the agent has been successfully deleted.
*/
const deleteAgent = async (searchParameter) => {
const agent = await Agent.findOneAndDelete(searchParameter);
if (agent) {
await removeAgentFromAllProjects(agent.id);
await Promise.all([
removeAllPermissions({
resourceType: ResourceType.AGENT,
resourceId: agent._id,
}),
removeAllPermissions({
resourceType: ResourceType.REMOTE_AGENT,
resourceId: agent._id,
}),
]);
try {
await Agent.updateMany({ 'edges.to': agent.id }, { $pull: { edges: { to: agent.id } } });
} catch (error) {
logger.error('[deleteAgent] Error removing agent from handoff edges', error);
}
try {
await User.updateMany(
{ 'favorites.agentId': agent.id },
{ $pull: { favorites: { agentId: agent.id } } },
);
} catch (error) {
logger.error('[deleteAgent] Error removing agent from user favorites', error);
}
}
return agent;
};
/**
* Deletes all agents created by a specific user.
* @param {string} userId - The ID of the user whose agents should be deleted.
* @returns {Promise<void>} A promise that resolves when all user agents have been deleted.
*/
const deleteUserAgents = async (userId) => {
try {
const userAgents = await getAgents({ author: userId });
if (userAgents.length === 0) {
return;
}
const agentIds = userAgents.map((agent) => agent.id);
const agentObjectIds = userAgents.map((agent) => agent._id);
for (const agentId of agentIds) {
await removeAgentFromAllProjects(agentId);
}
await AclEntry.deleteMany({
resourceType: { $in: [ResourceType.AGENT, ResourceType.REMOTE_AGENT] },
resourceId: { $in: agentObjectIds },
});
try {
await User.updateMany(
{ 'favorites.agentId': { $in: agentIds } },
{ $pull: { favorites: { agentId: { $in: agentIds } } } },
);
} catch (error) {
logger.error('[deleteUserAgents] Error removing agents from user favorites', error);
}
await Agent.deleteMany({ author: userId });
} catch (error) {
logger.error('[deleteUserAgents] General error:', error);
}
};
/**
* Get agents by accessible IDs with optional cursor-based pagination.
* @param {Object} params - The parameters for getting accessible agents.
* @param {Array} [params.accessibleIds] - Array of agent ObjectIds the user has ACL access to.
* @param {Object} [params.otherParams] - Additional query parameters (including author filter).
* @param {number} [params.limit] - Number of agents to return (max 100). If not provided, returns all agents.
* @param {string} [params.after] - Cursor for pagination - get agents after this cursor. // base64 encoded JSON string with updatedAt and _id.
* @returns {Promise<Object>} A promise that resolves to an object containing the agents data and pagination info.
*/
const getListAgentsByAccess = async ({
accessibleIds = [],
otherParams = {},
limit = null,
after = null,
}) => {
const isPaginated = limit !== null && limit !== undefined;
const normalizedLimit = isPaginated ? Math.min(Math.max(1, parseInt(limit) || 20), 100) : null;
// Build base query combining ACL accessible agents with other filters
const baseQuery = { ...otherParams, _id: { $in: accessibleIds } };
// Add cursor condition
if (after) {
try {
const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8'));
const { updatedAt, _id } = cursor;
const cursorCondition = {
$or: [
{ updatedAt: { $lt: new Date(updatedAt) } },
{ updatedAt: new Date(updatedAt), _id: { $gt: new mongoose.Types.ObjectId(_id) } },
],
};
// Merge cursor condition with base query
if (Object.keys(baseQuery).length > 0) {
baseQuery.$and = [{ ...baseQuery }, cursorCondition];
// Remove the original conditions from baseQuery to avoid duplication
Object.keys(baseQuery).forEach((key) => {
if (key !== '$and') delete baseQuery[key];
});
} else {
Object.assign(baseQuery, cursorCondition);
}
} catch (error) {
logger.warn('Invalid cursor:', error.message);
}
}
let query = Agent.find(baseQuery, {
id: 1,
_id: 1,
name: 1,
avatar: 1,
author: 1,
projectIds: 1,
description: 1,
updatedAt: 1,
category: 1,
support_contact: 1,
is_promoted: 1,
}).sort({ updatedAt: -1, _id: 1 });
// Only apply limit if pagination is requested
if (isPaginated) {
query = query.limit(normalizedLimit + 1);
}
const agents = await query.lean();
const hasMore = isPaginated ? agents.length > normalizedLimit : false;
const data = (isPaginated ? agents.slice(0, normalizedLimit) : agents).map((agent) => {
if (agent.author) {
agent.author = agent.author.toString();
}
return agent;
});
// Generate next cursor only if paginated
let nextCursor = null;
if (isPaginated && hasMore && data.length > 0) {
const lastAgent = agents[normalizedLimit - 1];
nextCursor = Buffer.from(
JSON.stringify({
updatedAt: lastAgent.updatedAt.toISOString(),
_id: lastAgent._id.toString(),
}),
).toString('base64');
}
return {
object: 'list',
data,
first_id: data.length > 0 ? data[0].id : null,
last_id: data.length > 0 ? data[data.length - 1].id : null,
has_more: hasMore,
after: nextCursor,
};
};
/**
* Updates the projects associated with an agent, adding and removing project IDs as specified.
* This function also updates the corresponding projects to include or exclude the agent ID.
*
* @param {Object} params - Parameters for updating the agent's projects.
* @param {IUser} params.user - Parameters for updating the agent's projects.
* @param {string} params.agentId - The ID of the agent to update.
* @param {string[]} [params.projectIds] - Array of project IDs to add to the agent.
* @param {string[]} [params.removeProjectIds] - Array of project IDs to remove from the agent.
* @returns {Promise<MongoAgent>} The updated agent document.
* @throws {Error} If there's an error updating the agent or projects.
*/
const updateAgentProjects = async ({ user, agentId, projectIds, removeProjectIds }) => {
const updateOps = {};
if (removeProjectIds && removeProjectIds.length > 0) {
for (const projectId of removeProjectIds) {
await removeAgentIdsFromProject(projectId, [agentId]);
}
updateOps.$pull = { projectIds: { $in: removeProjectIds } };
}
if (projectIds && projectIds.length > 0) {
for (const projectId of projectIds) {
await addAgentIdsToProject(projectId, [agentId]);
}
updateOps.$addToSet = { projectIds: { $each: projectIds } };
}
if (Object.keys(updateOps).length === 0) {
return await getAgent({ id: agentId });
}
const updateQuery = { id: agentId, author: user.id };
if (user.role === SystemRoles.ADMIN) {
delete updateQuery.author;
}
const updatedAgent = await updateAgent(updateQuery, updateOps, {
updatingUserId: user.id,
skipVersioning: true,
});
if (updatedAgent) {
return updatedAgent;
}
if (updateOps.$addToSet) {
for (const projectId of projectIds) {
await removeAgentIdsFromProject(projectId, [agentId]);
}
} else if (updateOps.$pull) {
for (const projectId of removeProjectIds) {
await addAgentIdsToProject(projectId, [agentId]);
}
}
return await getAgent({ id: agentId });
};
/**
* Reverts an agent to a specific version in its version history.
* @param {Object} searchParameter - The search parameters to find the agent to revert.
* @param {string} searchParameter.id - The ID of the agent to revert.
* @param {string} [searchParameter.author] - The user ID of the agent's author.
* @param {number} versionIndex - The index of the version to revert to in the versions array.
* @returns {Promise<MongoAgent>} The updated agent document after reverting.
* @throws {Error} If the agent is not found or the specified version does not exist.
*/
const revertAgentVersion = async (searchParameter, versionIndex) => {
const agent = await Agent.findOne(searchParameter);
if (!agent) {
throw new Error('Agent not found');
}
if (!agent.versions || !agent.versions[versionIndex]) {
throw new Error(`Version ${versionIndex} not found`);
}
const revertToVersion = agent.versions[versionIndex];
const updateData = {
...revertToVersion,
};
delete updateData._id;
delete updateData.id;
delete updateData.versions;
delete updateData.author;
delete updateData.updatedBy;
return Agent.findOneAndUpdate(searchParameter, updateData, { new: true }).lean();
};
/**
* Generates a hash of action metadata for version comparison
* @param {string[]} actionIds - Array of action IDs in format "domain_action_id"
* @param {Action[]} actions - Array of action documents
* @returns {Promise<string>} - SHA256 hash of the action metadata
*/
const generateActionMetadataHash = async (actionIds, actions) => {
if (!actionIds || actionIds.length === 0) {
return '';
}
// Create a map of action_id to metadata for quick lookup
const actionMap = new Map();
actions.forEach((action) => {
actionMap.set(action.action_id, action.metadata);
});
// Sort action IDs for consistent hashing
const sortedActionIds = [...actionIds].sort();
// Build a deterministic string representation of all action metadata
const metadataString = sortedActionIds
.map((actionFullId) => {
// Extract just the action_id part (after the delimiter)
const parts = actionFullId.split(actionDelimiter);
const actionId = parts[1];
const metadata = actionMap.get(actionId);
if (!metadata) {
return `${actionId}:null`;
}
// Sort metadata keys for deterministic output
const sortedKeys = Object.keys(metadata).sort();
const metadataStr = sortedKeys
.map((key) => `${key}:${JSON.stringify(metadata[key])}`)
.join(',');
return `${actionId}:{${metadataStr}}`;
})
.join(';');
// Use Web Crypto API to generate hash
const encoder = new TextEncoder();
const data = encoder.encode(metadataString);
const hashBuffer = await crypto.webcrypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
return hashHex;
};
/**
* Counts the number of promoted agents.
* @returns {Promise<number>} - The count of promoted agents
*/
const countPromotedAgents = async () => {
const count = await Agent.countDocuments({ is_promoted: true });
return count;
};
/**
* Load a default agent based on the endpoint
* @param {string} endpoint
* @returns {Agent | null}
*/
module.exports = {
getAgent,
getAgents,
loadAgent,
createAgent,
updateAgent,
deleteAgent,
deleteUserAgents,
revertAgentVersion,
updateAgentProjects,
countPromotedAgents,
addAgentResourceFile,
getListAgentsByAccess,
removeAgentResourceFiles,
generateActionMetadataHash,
};

View file

@ -1,62 +0,0 @@
const { Assistant } = require('~/db/models');
/**
* Update an assistant with new data without overwriting existing properties,
* or create a new assistant if it doesn't exist.
*
* @param {Object} searchParams - The search parameters to find the assistant to update.
* @param {string} searchParams.assistant_id - The ID of the assistant to update.
* @param {string} searchParams.user - The user ID of the assistant's author.
* @param {Object} updateData - An object containing the properties to update.
* @returns {Promise<AssistantDocument>} The updated or newly created assistant document as a plain object.
*/
const updateAssistantDoc = async (searchParams, updateData) => {
const options = { new: true, upsert: true };
return await Assistant.findOneAndUpdate(searchParams, updateData, options).lean();
};
/**
* Retrieves an assistant document based on the provided ID.
*
* @param {Object} searchParams - The search parameters to find the assistant to update.
* @param {string} searchParams.assistant_id - The ID of the assistant to update.
* @param {string} searchParams.user - The user ID of the assistant's author.
* @returns {Promise<AssistantDocument|null>} The assistant document as a plain object, or null if not found.
*/
const getAssistant = async (searchParams) => await Assistant.findOne(searchParams).lean();
/**
* Retrieves all assistants that match the given search parameters.
*
* @param {Object} searchParams - The search parameters to find matching assistants.
* @param {Object} [select] - Optional. Specifies which document fields to include or exclude.
* @returns {Promise<Array<AssistantDocument>>} A promise that resolves to an array of assistant documents as plain objects.
*/
const getAssistants = async (searchParams, select = null) => {
let query = Assistant.find(searchParams);
if (select) {
query = query.select(select);
}
return await query.lean();
};
/**
* Deletes an assistant based on the provided ID.
*
* @param {Object} searchParams - The search parameters to find the assistant to delete.
* @param {string} searchParams.assistant_id - The ID of the assistant to delete.
* @param {string} searchParams.user - The user ID of the assistant's author.
* @returns {Promise<void>} Resolves when the assistant has been successfully deleted.
*/
const deleteAssistant = async (searchParams) => {
return await Assistant.findOneAndDelete(searchParams);
};
module.exports = {
updateAssistantDoc,
deleteAssistant,
getAssistants,
getAssistant,
};

View file

@ -1,28 +0,0 @@
const { logger } = require('@librechat/data-schemas');
const { Banner } = require('~/db/models');
/**
* Retrieves the current active banner.
* @returns {Promise<Object|null>} The active banner object or null if no active banner is found.
*/
const getBanner = async (user) => {
try {
const now = new Date();
const banner = await Banner.findOne({
displayFrom: { $lte: now },
$or: [{ displayTo: { $gte: now } }, { displayTo: null }],
type: 'banner',
}).lean();
if (!banner || banner.isPublic || user) {
return banner;
}
return null;
} catch (error) {
logger.error('[getBanners] Error getting banners', error);
throw new Error('Error getting banners');
}
};
module.exports = { getBanner };

View file

@ -1,57 +0,0 @@
const { logger } = require('@librechat/data-schemas');
const options = [
{
label: 'com_ui_idea',
value: 'idea',
},
{
label: 'com_ui_travel',
value: 'travel',
},
{
label: 'com_ui_teach_or_explain',
value: 'teach_or_explain',
},
{
label: 'com_ui_write',
value: 'write',
},
{
label: 'com_ui_shop',
value: 'shop',
},
{
label: 'com_ui_code',
value: 'code',
},
{
label: 'com_ui_misc',
value: 'misc',
},
{
label: 'com_ui_roleplay',
value: 'roleplay',
},
{
label: 'com_ui_finance',
value: 'finance',
},
];
module.exports = {
/**
* Retrieves the categories asynchronously.
* @returns {Promise<TGetCategoriesResponse>} An array of category objects.
* @throws {Error} If there is an error retrieving the categories.
*/
getCategories: async () => {
try {
// const categories = await Categories.find();
return options;
} catch (error) {
logger.error('Error getting categories', error);
return [];
}
},
};

View file

@ -1,373 +0,0 @@
const { logger } = require('@librechat/data-schemas');
const { createTempChatExpirationDate } = require('@librechat/api');
const { getMessages, deleteMessages } = require('./Message');
const { Conversation } = require('~/db/models');
/**
* Searches for a conversation by conversationId and returns a lean document with only conversationId and user.
* @param {string} conversationId - The conversation's ID.
* @returns {Promise<{conversationId: string, user: string} | null>} The conversation object with selected fields or null if not found.
*/
const searchConversation = async (conversationId) => {
try {
return await Conversation.findOne({ conversationId }, 'conversationId user').lean();
} catch (error) {
logger.error('[searchConversation] Error searching conversation', error);
throw new Error('Error searching conversation');
}
};
/**
* Retrieves a single conversation for a given user and conversation ID.
* @param {string} user - The user's ID.
* @param {string} conversationId - The conversation's ID.
* @returns {Promise<TConversation>} The conversation object.
*/
const getConvo = async (user, conversationId) => {
try {
return await Conversation.findOne({ user, conversationId }).lean();
} catch (error) {
logger.error('[getConvo] Error getting single conversation', error);
throw new Error('Error getting single conversation');
}
};
const deleteNullOrEmptyConversations = async () => {
try {
const filter = {
$or: [
{ conversationId: null },
{ conversationId: '' },
{ conversationId: { $exists: false } },
],
};
const result = await Conversation.deleteMany(filter);
// Delete associated messages
const messageDeleteResult = await deleteMessages(filter);
logger.info(
`[deleteNullOrEmptyConversations] Deleted ${result.deletedCount} conversations and ${messageDeleteResult.deletedCount} messages`,
);
return {
conversations: result,
messages: messageDeleteResult,
};
} catch (error) {
logger.error('[deleteNullOrEmptyConversations] Error deleting conversations', error);
throw new Error('Error deleting conversations with null or empty conversationId');
}
};
/**
* Searches for a conversation by conversationId and returns associated file ids.
* @param {string} conversationId - The conversation's ID.
* @returns {Promise<string[] | null>}
*/
const getConvoFiles = async (conversationId) => {
try {
return (await Conversation.findOne({ conversationId }, 'files').lean())?.files ?? [];
} catch (error) {
logger.error('[getConvoFiles] Error getting conversation files', error);
throw new Error('Error getting conversation files');
}
};
module.exports = {
getConvoFiles,
searchConversation,
deleteNullOrEmptyConversations,
/**
* Saves a conversation to the database.
* @param {Object} req - The request object.
* @param {string} conversationId - The conversation's ID.
* @param {Object} metadata - Additional metadata to log for operation.
* @returns {Promise<TConversation>} The conversation object.
*/
saveConvo: async (req, { conversationId, newConversationId, ...convo }, metadata) => {
try {
if (metadata?.context) {
logger.debug(`[saveConvo] ${metadata.context}`);
}
const messages = await getMessages({ conversationId }, '_id');
const update = { ...convo, messages, user: req.user.id };
if (newConversationId) {
update.conversationId = newConversationId;
}
if (req?.body?.isTemporary) {
try {
const appConfig = req.config;
update.expiredAt = createTempChatExpirationDate(appConfig?.interfaceConfig);
} catch (err) {
logger.error('Error creating temporary chat expiration date:', err);
logger.info(`---\`saveConvo\` context: ${metadata?.context}`);
update.expiredAt = null;
}
} else {
update.expiredAt = null;
}
/** @type {{ $set: Partial<TConversation>; $unset?: Record<keyof TConversation, number> }} */
const updateOperation = { $set: update };
if (metadata && metadata.unsetFields && Object.keys(metadata.unsetFields).length > 0) {
updateOperation.$unset = metadata.unsetFields;
}
/** Note: the resulting Model object is necessary for Meilisearch operations */
const conversation = await Conversation.findOneAndUpdate(
{ conversationId, user: req.user.id },
updateOperation,
{
new: true,
upsert: metadata?.noUpsert !== true,
},
);
if (!conversation) {
logger.debug('[saveConvo] Conversation not found, skipping update');
return null;
}
return conversation.toObject();
} catch (error) {
logger.error('[saveConvo] Error saving conversation', error);
if (metadata && metadata?.context) {
logger.info(`[saveConvo] ${metadata.context}`);
}
return { message: 'Error saving conversation' };
}
},
bulkSaveConvos: async (conversations) => {
try {
const bulkOps = conversations.map((convo) => ({
updateOne: {
filter: { conversationId: convo.conversationId, user: convo.user },
update: convo,
upsert: true,
timestamps: false,
},
}));
const result = await Conversation.bulkWrite(bulkOps);
return result;
} catch (error) {
logger.error('[bulkSaveConvos] Error saving conversations in bulk', error);
throw new Error('Failed to save conversations in bulk.');
}
},
getConvosByCursor: async (
user,
{
cursor,
limit = 25,
isArchived = false,
tags,
search,
sortBy = 'updatedAt',
sortDirection = 'desc',
} = {},
) => {
const filters = [{ user }];
if (isArchived) {
filters.push({ isArchived: true });
} else {
filters.push({ $or: [{ isArchived: false }, { isArchived: { $exists: false } }] });
}
if (Array.isArray(tags) && tags.length > 0) {
filters.push({ tags: { $in: tags } });
}
filters.push({ $or: [{ expiredAt: null }, { expiredAt: { $exists: false } }] });
if (search) {
try {
const meiliResults = await Conversation.meiliSearch(search, { filter: `user = "${user}"` });
const matchingIds = Array.isArray(meiliResults.hits)
? meiliResults.hits.map((result) => result.conversationId)
: [];
if (!matchingIds.length) {
return { conversations: [], nextCursor: null };
}
filters.push({ conversationId: { $in: matchingIds } });
} catch (error) {
logger.error('[getConvosByCursor] Error during meiliSearch', error);
throw new Error('Error during meiliSearch');
}
}
const validSortFields = ['title', 'createdAt', 'updatedAt'];
if (!validSortFields.includes(sortBy)) {
throw new Error(
`Invalid sortBy field: ${sortBy}. Must be one of ${validSortFields.join(', ')}`,
);
}
const finalSortBy = sortBy;
const finalSortDirection = sortDirection === 'asc' ? 'asc' : 'desc';
let cursorFilter = null;
if (cursor) {
try {
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString());
const { primary, secondary } = decoded;
const primaryValue = finalSortBy === 'title' ? primary : new Date(primary);
const secondaryValue = new Date(secondary);
const op = finalSortDirection === 'asc' ? '$gt' : '$lt';
cursorFilter = {
$or: [
{ [finalSortBy]: { [op]: primaryValue } },
{
[finalSortBy]: primaryValue,
updatedAt: { [op]: secondaryValue },
},
],
};
} catch (_err) {
logger.warn('[getConvosByCursor] Invalid cursor format, starting from beginning');
}
if (cursorFilter) {
filters.push(cursorFilter);
}
}
const query = filters.length === 1 ? filters[0] : { $and: filters };
try {
const sortOrder = finalSortDirection === 'asc' ? 1 : -1;
const sortObj = { [finalSortBy]: sortOrder };
if (finalSortBy !== 'updatedAt') {
sortObj.updatedAt = sortOrder;
}
const convos = await Conversation.find(query)
.select(
'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL',
)
.sort(sortObj)
.limit(limit + 1)
.lean();
let nextCursor = null;
if (convos.length > limit) {
convos.pop(); // Remove extra item used to detect next page
// Create cursor from the last RETURNED item (not the popped one)
const lastReturned = convos[convos.length - 1];
const primaryValue = lastReturned[finalSortBy];
const primaryStr = finalSortBy === 'title' ? primaryValue : primaryValue.toISOString();
const secondaryStr = lastReturned.updatedAt.toISOString();
const composite = { primary: primaryStr, secondary: secondaryStr };
nextCursor = Buffer.from(JSON.stringify(composite)).toString('base64');
}
return { conversations: convos, nextCursor };
} catch (error) {
logger.error('[getConvosByCursor] Error getting conversations', error);
throw new Error('Error getting conversations');
}
},
getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => {
try {
if (!convoIds?.length) {
return { conversations: [], nextCursor: null, convoMap: {} };
}
const conversationIds = convoIds.map((convo) => convo.conversationId);
const results = await Conversation.find({
user,
conversationId: { $in: conversationIds },
$or: [{ expiredAt: { $exists: false } }, { expiredAt: null }],
}).lean();
results.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
let filtered = results;
if (cursor && cursor !== 'start') {
const cursorDate = new Date(cursor);
filtered = results.filter((convo) => new Date(convo.updatedAt) < cursorDate);
}
const limited = filtered.slice(0, limit + 1);
let nextCursor = null;
if (limited.length > limit) {
limited.pop(); // Remove extra item used to detect next page
// Create cursor from the last RETURNED item (not the popped one)
nextCursor = limited[limited.length - 1].updatedAt.toISOString();
}
const convoMap = {};
limited.forEach((convo) => {
convoMap[convo.conversationId] = convo;
});
return { conversations: limited, nextCursor, convoMap };
} catch (error) {
logger.error('[getConvosQueried] Error getting conversations', error);
throw new Error('Error fetching conversations');
}
},
getConvo,
/* chore: this method is not properly error handled */
getConvoTitle: async (user, conversationId) => {
try {
const convo = await getConvo(user, conversationId);
/* ChatGPT Browser was triggering error here due to convo being saved later */
if (convo && !convo.title) {
return null;
} else {
// TypeError: Cannot read properties of null (reading 'title')
return convo?.title || 'New Chat';
}
} catch (error) {
logger.error('[getConvoTitle] Error getting conversation title', error);
throw new Error('Error getting conversation title');
}
},
/**
* Asynchronously deletes conversations and associated messages for a given user and filter.
*
* @async
* @function
* @param {string|ObjectId} user - The user's ID.
* @param {Object} filter - Additional filter criteria for the conversations to be deleted.
* @returns {Promise<{ n: number, ok: number, deletedCount: number, messages: { n: number, ok: number, deletedCount: number } }>}
* An object containing the count of deleted conversations and associated messages.
* @throws {Error} Throws an error if there's an issue with the database operations.
*
* @example
* const user = 'someUserId';
* const filter = { someField: 'someValue' };
* const result = await deleteConvos(user, filter);
* logger.error(result); // { n: 5, ok: 1, deletedCount: 5, messages: { n: 10, ok: 1, deletedCount: 10 } }
*/
deleteConvos: async (user, filter) => {
try {
const userFilter = { ...filter, user };
const conversations = await Conversation.find(userFilter).select('conversationId');
const conversationIds = conversations.map((c) => c.conversationId);
if (!conversationIds.length) {
throw new Error('Conversation not found or already deleted.');
}
const deleteConvoResult = await Conversation.deleteMany(userFilter);
const deleteMessagesResult = await deleteMessages({
conversationId: { $in: conversationIds },
user,
});
return { ...deleteConvoResult, messages: deleteMessagesResult };
} catch (error) {
logger.error('[deleteConvos] Error deleting conversations and messages', error);
throw error;
}
},
};

View file

@ -1,284 +0,0 @@
const { logger } = require('@librechat/data-schemas');
const { ConversationTag, Conversation } = require('~/db/models');
/**
* Retrieves all conversation tags for a user.
* @param {string} user - The user ID.
* @returns {Promise<Array>} An array of conversation tags.
*/
const getConversationTags = async (user) => {
try {
return await ConversationTag.find({ user }).sort({ position: 1 }).lean();
} catch (error) {
logger.error('[getConversationTags] Error getting conversation tags', error);
throw new Error('Error getting conversation tags');
}
};
/**
* Creates a new conversation tag.
* @param {string} user - The user ID.
* @param {Object} data - The tag data.
* @param {string} data.tag - The tag name.
* @param {string} [data.description] - The tag description.
* @param {boolean} [data.addToConversation] - Whether to add the tag to a conversation.
* @param {string} [data.conversationId] - The conversation ID to add the tag to.
* @returns {Promise<Object>} The created tag.
*/
const createConversationTag = async (user, data) => {
try {
const { tag, description, addToConversation, conversationId } = data;
const existingTag = await ConversationTag.findOne({ user, tag }).lean();
if (existingTag) {
return existingTag;
}
const maxPosition = await ConversationTag.findOne({ user }).sort('-position').lean();
const position = (maxPosition?.position || 0) + 1;
const newTag = await ConversationTag.findOneAndUpdate(
{ tag, user },
{
tag,
user,
count: addToConversation ? 1 : 0,
position,
description,
$setOnInsert: { createdAt: new Date() },
},
{
new: true,
upsert: true,
lean: true,
},
);
if (addToConversation && conversationId) {
await Conversation.findOneAndUpdate(
{ user, conversationId },
{ $addToSet: { tags: tag } },
{ new: true },
);
}
return newTag;
} catch (error) {
logger.error('[createConversationTag] Error creating conversation tag', error);
throw new Error('Error creating conversation tag');
}
};
/**
* Updates an existing conversation tag.
* @param {string} user - The user ID.
* @param {string} oldTag - The current tag name.
* @param {Object} data - The updated tag data.
* @param {string} [data.tag] - The new tag name.
* @param {string} [data.description] - The updated description.
* @param {number} [data.position] - The new position.
* @returns {Promise<Object>} The updated tag.
*/
const updateConversationTag = async (user, oldTag, data) => {
try {
const { tag: newTag, description, position } = data;
const existingTag = await ConversationTag.findOne({ user, tag: oldTag }).lean();
if (!existingTag) {
return null;
}
if (newTag && newTag !== oldTag) {
const tagAlreadyExists = await ConversationTag.findOne({ user, tag: newTag }).lean();
if (tagAlreadyExists) {
throw new Error('Tag already exists');
}
await Conversation.updateMany({ user, tags: oldTag }, { $set: { 'tags.$': newTag } });
}
const updateData = {};
if (newTag) {
updateData.tag = newTag;
}
if (description !== undefined) {
updateData.description = description;
}
if (position !== undefined) {
await adjustPositions(user, existingTag.position, position);
updateData.position = position;
}
return await ConversationTag.findOneAndUpdate({ user, tag: oldTag }, updateData, {
new: true,
lean: true,
});
} catch (error) {
logger.error('[updateConversationTag] Error updating conversation tag', error);
throw new Error('Error updating conversation tag');
}
};
/**
* Adjusts positions of tags when a tag's position is changed.
* @param {string} user - The user ID.
* @param {number} oldPosition - The old position of the tag.
* @param {number} newPosition - The new position of the tag.
* @returns {Promise<void>}
*/
const adjustPositions = async (user, oldPosition, newPosition) => {
if (oldPosition === newPosition) {
return;
}
const update = oldPosition < newPosition ? { $inc: { position: -1 } } : { $inc: { position: 1 } };
const position =
oldPosition < newPosition
? {
$gt: Math.min(oldPosition, newPosition),
$lte: Math.max(oldPosition, newPosition),
}
: {
$gte: Math.min(oldPosition, newPosition),
$lt: Math.max(oldPosition, newPosition),
};
await ConversationTag.updateMany(
{
user,
position,
},
update,
);
};
/**
* Deletes a conversation tag.
* @param {string} user - The user ID.
* @param {string} tag - The tag to delete.
* @returns {Promise<Object>} The deleted tag.
*/
const deleteConversationTag = async (user, tag) => {
try {
const deletedTag = await ConversationTag.findOneAndDelete({ user, tag }).lean();
if (!deletedTag) {
return null;
}
await Conversation.updateMany({ user, tags: tag }, { $pull: { tags: tag } });
await ConversationTag.updateMany(
{ user, position: { $gt: deletedTag.position } },
{ $inc: { position: -1 } },
);
return deletedTag;
} catch (error) {
logger.error('[deleteConversationTag] Error deleting conversation tag', error);
throw new Error('Error deleting conversation tag');
}
};
/**
* Updates tags for a specific conversation.
* @param {string} user - The user ID.
* @param {string} conversationId - The conversation ID.
* @param {string[]} tags - The new set of tags for the conversation.
* @returns {Promise<string[]>} The updated list of tags for the conversation.
*/
const updateTagsForConversation = async (user, conversationId, tags) => {
try {
const conversation = await Conversation.findOne({ user, conversationId }).lean();
if (!conversation) {
throw new Error('Conversation not found');
}
const oldTags = new Set(conversation.tags);
const newTags = new Set(tags);
const addedTags = [...newTags].filter((tag) => !oldTags.has(tag));
const removedTags = [...oldTags].filter((tag) => !newTags.has(tag));
const bulkOps = [];
for (const tag of addedTags) {
bulkOps.push({
updateOne: {
filter: { user, tag },
update: { $inc: { count: 1 } },
upsert: true,
},
});
}
for (const tag of removedTags) {
bulkOps.push({
updateOne: {
filter: { user, tag },
update: { $inc: { count: -1 } },
},
});
}
if (bulkOps.length > 0) {
await ConversationTag.bulkWrite(bulkOps);
}
const updatedConversation = (
await Conversation.findOneAndUpdate(
{ user, conversationId },
{ $set: { tags: [...newTags] } },
{ new: true },
)
).toObject();
return updatedConversation.tags;
} catch (error) {
logger.error('[updateTagsForConversation] Error updating tags', error);
throw new Error('Error updating tags for conversation');
}
};
/**
* Increments tag counts for existing tags only.
* @param {string} user - The user ID.
* @param {string[]} tags - Array of tag names to increment
* @returns {Promise<void>}
*/
const bulkIncrementTagCounts = async (user, tags) => {
if (!tags || tags.length === 0) {
return;
}
try {
const uniqueTags = [...new Set(tags.filter(Boolean))];
if (uniqueTags.length === 0) {
return;
}
const bulkOps = uniqueTags.map((tag) => ({
updateOne: {
filter: { user, tag },
update: { $inc: { count: 1 } },
},
}));
const result = await ConversationTag.bulkWrite(bulkOps);
if (result && result.modifiedCount > 0) {
logger.debug(
`user: ${user} | Incremented tag counts - modified ${result.modifiedCount} tags`,
);
}
} catch (error) {
logger.error('[bulkIncrementTagCounts] Error incrementing tag counts', error);
}
};
module.exports = {
getConversationTags,
createConversationTag,
updateConversationTag,
deleteConversationTag,
bulkIncrementTagCounts,
updateTagsForConversation,
};

View file

@ -1,250 +0,0 @@
const { logger } = require('@librechat/data-schemas');
const { EToolResources, FileContext } = require('librechat-data-provider');
const { File } = require('~/db/models');
/**
* Finds a file by its file_id with additional query options.
* @param {string} file_id - The unique identifier of the file.
* @param {object} options - Query options for filtering, projection, etc.
* @returns {Promise<MongoFile>} A promise that resolves to the file document or null.
*/
const findFileById = async (file_id, options = {}) => {
return await File.findOne({ file_id, ...options }).lean();
};
/**
* Retrieves files matching a given filter, sorted by the most recently updated.
* @param {Object} filter - The filter criteria to apply.
* @param {Object} [_sortOptions] - Optional sort parameters.
* @param {Object|String} [selectFields={ text: 0 }] - Fields to include/exclude in the query results.
* Default excludes the 'text' field.
* @returns {Promise<Array<MongoFile>>} A promise that resolves to an array of file documents.
*/
const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }) => {
const sortOptions = { updatedAt: -1, ..._sortOptions };
return await File.find(filter).select(selectFields).sort(sortOptions).lean();
};
/**
* Retrieves tool files (files that are embedded or have a fileIdentifier) from an array of file IDs.
* Note: execute_code files are handled separately by getCodeGeneratedFiles.
* @param {string[]} fileIds - Array of file_id strings to search for
* @param {Set<EToolResources>} toolResourceSet - Optional filter for tool resources
* @returns {Promise<Array<MongoFile>>} Files that match the criteria
*/
const getToolFilesByIds = async (fileIds, toolResourceSet) => {
if (!fileIds || !fileIds.length || !toolResourceSet?.size) {
return [];
}
try {
const orConditions = [];
if (toolResourceSet.has(EToolResources.context)) {
orConditions.push({ text: { $exists: true, $ne: null }, context: FileContext.agents });
}
if (toolResourceSet.has(EToolResources.file_search)) {
orConditions.push({ embedded: true });
}
if (orConditions.length === 0) {
return [];
}
const filter = {
file_id: { $in: fileIds },
context: { $ne: FileContext.execute_code }, // Exclude code-generated files
$or: orConditions,
};
const selectFields = { text: 0 };
const sortOptions = { updatedAt: -1 };
return await getFiles(filter, sortOptions, selectFields);
} catch (error) {
logger.error('[getToolFilesByIds] Error retrieving tool files:', error);
throw new Error('Error retrieving tool files');
}
};
/**
* Retrieves files generated by code execution for a given conversation.
* These files are stored locally with fileIdentifier metadata for code env re-upload.
* @param {string} conversationId - The conversation ID to search for
* @param {string[]} [messageIds] - Optional array of messageIds to filter by (for linear thread filtering)
* @returns {Promise<Array<MongoFile>>} Files generated by code execution in the conversation
*/
const getCodeGeneratedFiles = async (conversationId, messageIds) => {
if (!conversationId) {
return [];
}
/** messageIds are required for proper thread filtering of code-generated files */
if (!messageIds || messageIds.length === 0) {
return [];
}
try {
const filter = {
conversationId,
context: FileContext.execute_code,
messageId: { $exists: true, $in: messageIds },
'metadata.fileIdentifier': { $exists: true },
};
const selectFields = { text: 0 };
const sortOptions = { createdAt: 1 };
return await getFiles(filter, sortOptions, selectFields);
} catch (error) {
logger.error('[getCodeGeneratedFiles] Error retrieving code generated files:', error);
return [];
}
};
/**
* Retrieves user-uploaded execute_code files (not code-generated) by their file IDs.
* These are files with fileIdentifier metadata but context is NOT execute_code (e.g., agents or message_attachment).
* File IDs should be collected from message.files arrays in the current thread.
* @param {string[]} fileIds - Array of file IDs to fetch (from message.files in the thread)
* @returns {Promise<Array<MongoFile>>} User-uploaded execute_code files
*/
const getUserCodeFiles = async (fileIds) => {
if (!fileIds || fileIds.length === 0) {
return [];
}
try {
const filter = {
file_id: { $in: fileIds },
context: { $ne: FileContext.execute_code },
'metadata.fileIdentifier': { $exists: true },
};
const selectFields = { text: 0 };
const sortOptions = { createdAt: 1 };
return await getFiles(filter, sortOptions, selectFields);
} catch (error) {
logger.error('[getUserCodeFiles] Error retrieving user code files:', error);
return [];
}
};
/**
* Creates a new file with a TTL of 1 hour.
* @param {MongoFile} data - The file data to be created, must contain file_id.
* @param {boolean} disableTTL - Whether to disable the TTL.
* @returns {Promise<MongoFile>} A promise that resolves to the created file document.
*/
const createFile = async (data, disableTTL) => {
const fileData = {
...data,
expiresAt: new Date(Date.now() + 3600 * 1000),
};
if (disableTTL) {
delete fileData.expiresAt;
}
return await File.findOneAndUpdate({ file_id: data.file_id }, fileData, {
new: true,
upsert: true,
}).lean();
};
/**
* Updates a file identified by file_id with new data and removes the TTL.
* @param {MongoFile} data - The data to update, must contain file_id.
* @returns {Promise<MongoFile>} A promise that resolves to the updated file document.
*/
const updateFile = async (data) => {
const { file_id, ...update } = data;
const updateOperation = {
$set: update,
$unset: { expiresAt: '' }, // Remove the expiresAt field to prevent TTL
};
return await File.findOneAndUpdate({ file_id }, updateOperation, { new: true }).lean();
};
/**
* Increments the usage of a file identified by file_id.
* @param {MongoFile} data - The data to update, must contain file_id and the increment value for usage.
* @returns {Promise<MongoFile>} A promise that resolves to the updated file document.
*/
const updateFileUsage = async (data) => {
const { file_id, inc = 1 } = data;
const updateOperation = {
$inc: { usage: inc },
$unset: { expiresAt: '', temp_file_id: '' },
};
return await File.findOneAndUpdate({ file_id }, updateOperation, { new: true }).lean();
};
/**
* Deletes a file identified by file_id.
* @param {string} file_id - The unique identifier of the file to delete.
* @returns {Promise<MongoFile>} A promise that resolves to the deleted file document or null.
*/
const deleteFile = async (file_id) => {
return await File.findOneAndDelete({ file_id }).lean();
};
/**
* Deletes a file identified by a filter.
* @param {object} filter - The filter criteria to apply.
* @returns {Promise<MongoFile>} A promise that resolves to the deleted file document or null.
*/
const deleteFileByFilter = async (filter) => {
return await File.findOneAndDelete(filter).lean();
};
/**
* Deletes multiple files identified by an array of file_ids.
* @param {Array<string>} file_ids - The unique identifiers of the files to delete.
* @returns {Promise<Object>} A promise that resolves to the result of the deletion operation.
*/
const deleteFiles = async (file_ids, user) => {
let deleteQuery = { file_id: { $in: file_ids } };
if (user) {
deleteQuery = { user: user };
}
return await File.deleteMany(deleteQuery);
};
/**
* Batch updates files with new signed URLs in MongoDB
*
* @param {MongoFile[]} updates - Array of updates in the format { file_id, filepath }
* @returns {Promise<void>}
*/
async function batchUpdateFiles(updates) {
if (!updates || updates.length === 0) {
return;
}
const bulkOperations = updates.map((update) => ({
updateOne: {
filter: { file_id: update.file_id },
update: { $set: { filepath: update.filepath } },
},
}));
const result = await File.bulkWrite(bulkOperations);
logger.info(`Updated ${result.modifiedCount} files with new S3 URLs`);
}
module.exports = {
findFileById,
getFiles,
getToolFilesByIds,
getCodeGeneratedFiles,
getUserCodeFiles,
createFile,
updateFile,
updateFileUsage,
deleteFile,
deleteFiles,
deleteFileByFilter,
batchUpdateFiles,
};

View file

@ -1,736 +0,0 @@
const mongoose = require('mongoose');
const { v4: uuidv4 } = require('uuid');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { createModels, createMethods } = require('@librechat/data-schemas');
const {
SystemRoles,
ResourceType,
AccessRoleIds,
PrincipalType,
} = require('librechat-data-provider');
const { grantPermission } = require('~/server/services/PermissionService');
const { createAgent } = require('./Agent');
let File;
let Agent;
let AclEntry;
let User;
let modelsToCleanup = [];
let methods;
let getFiles;
let createFile;
let seedDefaultRoles;
describe('File Access Control', () => {
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
// Initialize all models
const models = createModels(mongoose);
// Track which models we're adding
modelsToCleanup = Object.keys(models);
// Register models on mongoose.models so methods can access them
const dbModels = require('~/db/models');
Object.assign(mongoose.models, dbModels);
File = dbModels.File;
Agent = dbModels.Agent;
AclEntry = dbModels.AclEntry;
User = dbModels.User;
// Create methods from data-schemas (includes file methods)
methods = createMethods(mongoose);
getFiles = methods.getFiles;
createFile = methods.createFile;
seedDefaultRoles = methods.seedDefaultRoles;
// Seed default roles
await seedDefaultRoles();
});
afterAll(async () => {
// Clean up all collections before disconnecting
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
// Clear only the models we added
for (const modelName of modelsToCleanup) {
if (mongoose.models[modelName]) {
delete mongoose.models[modelName];
}
}
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await File.deleteMany({});
await Agent.deleteMany({});
await AclEntry.deleteMany({});
await User.deleteMany({});
// Don't delete AccessRole as they are seeded defaults needed for tests
});
describe('hasAccessToFilesViaAgent', () => {
it('should efficiently check access for multiple files at once', async () => {
const userId = new mongoose.Types.ObjectId();
const authorId = new mongoose.Types.ObjectId();
const agentId = uuidv4();
const fileIds = [uuidv4(), uuidv4(), uuidv4(), uuidv4()];
// Create users
await User.create({
_id: userId,
email: 'user@example.com',
emailVerified: true,
provider: 'local',
});
await User.create({
_id: authorId,
email: 'author@example.com',
emailVerified: true,
provider: 'local',
});
// Create files
for (const fileId of fileIds) {
await createFile({
user: authorId,
file_id: fileId,
filename: `file-${fileId}.txt`,
filepath: `/uploads/${fileId}`,
});
}
// Create agent with only first two files attached
const agent = await createAgent({
id: agentId,
name: 'Test Agent',
author: authorId,
model: 'gpt-4',
provider: 'openai',
tool_resources: {
file_search: {
file_ids: [fileIds[0], fileIds[1]],
},
},
});
// Grant EDIT permission to user on the agent
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
grantedBy: authorId,
});
// Check access for all files
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
const accessMap = await hasAccessToFilesViaAgent({
userId: userId,
role: SystemRoles.USER,
fileIds,
agentId: agent.id, // Use agent.id which is the custom UUID
});
// Should have access only to the first two files
expect(accessMap.get(fileIds[0])).toBe(true);
expect(accessMap.get(fileIds[1])).toBe(true);
expect(accessMap.get(fileIds[2])).toBe(false);
expect(accessMap.get(fileIds[3])).toBe(false);
});
it('should only grant author access to files attached to the agent', async () => {
const authorId = new mongoose.Types.ObjectId();
const agentId = uuidv4();
const fileIds = [uuidv4(), uuidv4(), uuidv4()];
await User.create({
_id: authorId,
email: 'author@example.com',
emailVerified: true,
provider: 'local',
});
await createAgent({
id: agentId,
name: 'Test Agent',
author: authorId,
model: 'gpt-4',
provider: 'openai',
tool_resources: {
file_search: {
file_ids: [fileIds[0]],
},
},
});
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
const accessMap = await hasAccessToFilesViaAgent({
userId: authorId,
role: SystemRoles.USER,
fileIds,
agentId,
});
expect(accessMap.get(fileIds[0])).toBe(true);
expect(accessMap.get(fileIds[1])).toBe(false);
expect(accessMap.get(fileIds[2])).toBe(false);
});
it('should deny all access when agent has no tool_resources', async () => {
const authorId = new mongoose.Types.ObjectId();
const agentId = uuidv4();
const fileId = uuidv4();
await User.create({
_id: authorId,
email: 'author-no-resources@example.com',
emailVerified: true,
provider: 'local',
});
await createAgent({
id: agentId,
name: 'Bare Agent',
author: authorId,
model: 'gpt-4',
provider: 'openai',
});
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
const accessMap = await hasAccessToFilesViaAgent({
userId: authorId,
role: SystemRoles.USER,
fileIds: [fileId],
agentId,
});
expect(accessMap.get(fileId)).toBe(false);
});
it('should grant access to files across multiple resource types', async () => {
const authorId = new mongoose.Types.ObjectId();
const agentId = uuidv4();
const fileIds = [uuidv4(), uuidv4(), uuidv4()];
await User.create({
_id: authorId,
email: 'author-multi@example.com',
emailVerified: true,
provider: 'local',
});
await createAgent({
id: agentId,
name: 'Multi Resource Agent',
author: authorId,
model: 'gpt-4',
provider: 'openai',
tool_resources: {
file_search: {
file_ids: [fileIds[0]],
},
execute_code: {
file_ids: [fileIds[1]],
},
},
});
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
const accessMap = await hasAccessToFilesViaAgent({
userId: authorId,
role: SystemRoles.USER,
fileIds,
agentId,
});
expect(accessMap.get(fileIds[0])).toBe(true);
expect(accessMap.get(fileIds[1])).toBe(true);
expect(accessMap.get(fileIds[2])).toBe(false);
});
it('should grant author access to attached files when isDelete is true', async () => {
const authorId = new mongoose.Types.ObjectId();
const agentId = uuidv4();
const attachedFileId = uuidv4();
const unattachedFileId = uuidv4();
await User.create({
_id: authorId,
email: 'author-delete@example.com',
emailVerified: true,
provider: 'local',
});
await createAgent({
id: agentId,
name: 'Delete Test Agent',
author: authorId,
model: 'gpt-4',
provider: 'openai',
tool_resources: {
file_search: {
file_ids: [attachedFileId],
},
},
});
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
const accessMap = await hasAccessToFilesViaAgent({
userId: authorId,
role: SystemRoles.USER,
fileIds: [attachedFileId, unattachedFileId],
agentId,
isDelete: true,
});
expect(accessMap.get(attachedFileId)).toBe(true);
expect(accessMap.get(unattachedFileId)).toBe(false);
});
it('should handle non-existent agent gracefully', async () => {
const userId = new mongoose.Types.ObjectId();
const fileIds = [uuidv4(), uuidv4()];
// Create user
await User.create({
_id: userId,
email: 'user@example.com',
emailVerified: true,
provider: 'local',
});
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
const accessMap = await hasAccessToFilesViaAgent({
userId: userId,
role: SystemRoles.USER,
fileIds,
agentId: 'non-existent-agent',
});
// Should have no access to any files
expect(accessMap.get(fileIds[0])).toBe(false);
expect(accessMap.get(fileIds[1])).toBe(false);
});
it('should deny access when user only has VIEW permission and needs access for deletion', async () => {
const userId = new mongoose.Types.ObjectId();
const authorId = new mongoose.Types.ObjectId();
const agentId = uuidv4();
const fileIds = [uuidv4(), uuidv4()];
// Create users
await User.create({
_id: userId,
email: 'user@example.com',
emailVerified: true,
provider: 'local',
});
await User.create({
_id: authorId,
email: 'author@example.com',
emailVerified: true,
provider: 'local',
});
// Create agent with files
const agent = await createAgent({
id: agentId,
name: 'View-Only Agent',
author: authorId,
model: 'gpt-4',
provider: 'openai',
tool_resources: {
file_search: {
file_ids: fileIds,
},
},
});
// Grant only VIEW permission to user on the agent
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: authorId,
});
// Check access for files
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
const accessMap = await hasAccessToFilesViaAgent({
userId: userId,
role: SystemRoles.USER,
fileIds,
agentId,
isDelete: true,
});
// Should have no access to any files when only VIEW permission
expect(accessMap.get(fileIds[0])).toBe(false);
expect(accessMap.get(fileIds[1])).toBe(false);
});
it('should grant access when user has VIEW permission', async () => {
const userId = new mongoose.Types.ObjectId();
const authorId = new mongoose.Types.ObjectId();
const agentId = uuidv4();
const fileIds = [uuidv4(), uuidv4()];
// Create users
await User.create({
_id: userId,
email: 'user@example.com',
emailVerified: true,
provider: 'local',
});
await User.create({
_id: authorId,
email: 'author@example.com',
emailVerified: true,
provider: 'local',
});
// Create agent with files
const agent = await createAgent({
id: agentId,
name: 'View-Only Agent',
author: authorId,
model: 'gpt-4',
provider: 'openai',
tool_resources: {
file_search: {
file_ids: fileIds,
},
},
});
// Grant only VIEW permission to user on the agent
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: authorId,
});
// Check access for files
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
const accessMap = await hasAccessToFilesViaAgent({
userId: userId,
role: SystemRoles.USER,
fileIds,
agentId,
});
expect(accessMap.get(fileIds[0])).toBe(true);
expect(accessMap.get(fileIds[1])).toBe(true);
});
});
describe('getFiles with agent access control', () => {
test('should return files owned by user and files accessible through agent', async () => {
const authorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
const agentId = `agent_${uuidv4()}`;
const ownedFileId = `file_${uuidv4()}`;
const sharedFileId = `file_${uuidv4()}`;
const inaccessibleFileId = `file_${uuidv4()}`;
// Create users
await User.create({
_id: userId,
email: 'user@example.com',
emailVerified: true,
provider: 'local',
});
await User.create({
_id: authorId,
email: 'author@example.com',
emailVerified: true,
provider: 'local',
});
// Create agent with shared file
const agent = await createAgent({
id: agentId,
name: 'Shared Agent',
provider: 'test',
model: 'test-model',
author: authorId,
tool_resources: {
file_search: {
file_ids: [sharedFileId],
},
},
});
// Grant EDIT permission to user on the agent
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
grantedBy: authorId,
});
// Create files
await createFile({
file_id: ownedFileId,
user: userId,
filename: 'owned.txt',
filepath: '/uploads/owned.txt',
type: 'text/plain',
bytes: 100,
});
await createFile({
file_id: sharedFileId,
user: authorId,
filename: 'shared.txt',
filepath: '/uploads/shared.txt',
type: 'text/plain',
bytes: 200,
embedded: true,
});
await createFile({
file_id: inaccessibleFileId,
user: authorId,
filename: 'inaccessible.txt',
filepath: '/uploads/inaccessible.txt',
type: 'text/plain',
bytes: 300,
});
// Get all files first
const allFiles = await getFiles(
{ file_id: { $in: [ownedFileId, sharedFileId, inaccessibleFileId] } },
null,
{ text: 0 },
);
// Then filter by access control
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
const files = await filterFilesByAgentAccess({
files: allFiles,
userId: userId,
role: SystemRoles.USER,
agentId,
});
expect(files).toHaveLength(2);
expect(files.map((f) => f.file_id)).toContain(ownedFileId);
expect(files.map((f) => f.file_id)).toContain(sharedFileId);
expect(files.map((f) => f.file_id)).not.toContain(inaccessibleFileId);
});
test('should return all files when no userId/agentId provided', async () => {
const userId = new mongoose.Types.ObjectId();
const fileId1 = `file_${uuidv4()}`;
const fileId2 = `file_${uuidv4()}`;
await createFile({
file_id: fileId1,
user: userId,
filename: 'file1.txt',
filepath: '/uploads/file1.txt',
type: 'text/plain',
bytes: 100,
});
await createFile({
file_id: fileId2,
user: new mongoose.Types.ObjectId(),
filename: 'file2.txt',
filepath: '/uploads/file2.txt',
type: 'text/plain',
bytes: 200,
});
const files = await getFiles({ file_id: { $in: [fileId1, fileId2] } });
expect(files).toHaveLength(2);
});
});
describe('Role-based file permissions', () => {
it('should optimize permission checks when role is provided', async () => {
const userId = new mongoose.Types.ObjectId();
const authorId = new mongoose.Types.ObjectId();
const agentId = uuidv4();
const fileIds = [uuidv4(), uuidv4()];
// Create users
await User.create({
_id: userId,
email: 'user@example.com',
emailVerified: true,
provider: 'local',
role: 'ADMIN', // User has ADMIN role
});
await User.create({
_id: authorId,
email: 'author@example.com',
emailVerified: true,
provider: 'local',
});
// Create files
for (const fileId of fileIds) {
await createFile({
file_id: fileId,
user: authorId,
filename: `${fileId}.txt`,
filepath: `/uploads/${fileId}.txt`,
type: 'text/plain',
bytes: 100,
});
}
// Create agent with files
const agent = await createAgent({
id: agentId,
name: 'Test Agent',
author: authorId,
model: 'gpt-4',
provider: 'openai',
tool_resources: {
file_search: {
file_ids: fileIds,
},
},
});
// Grant permission to ADMIN role
await grantPermission({
principalType: PrincipalType.ROLE,
principalId: 'ADMIN',
resourceType: ResourceType.AGENT,
resourceId: agent._id,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
grantedBy: authorId,
});
// Check access with role provided (should avoid DB query)
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
const accessMapWithRole = await hasAccessToFilesViaAgent({
userId: userId,
role: 'ADMIN',
fileIds,
agentId: agent.id,
});
// User should have access through their ADMIN role
expect(accessMapWithRole.get(fileIds[0])).toBe(true);
expect(accessMapWithRole.get(fileIds[1])).toBe(true);
// Check access without role (will query DB to get user's role)
const accessMapWithoutRole = await hasAccessToFilesViaAgent({
userId: userId,
fileIds,
agentId: agent.id,
});
// Should have same result
expect(accessMapWithoutRole.get(fileIds[0])).toBe(true);
expect(accessMapWithoutRole.get(fileIds[1])).toBe(true);
});
it('should deny access when user role changes', async () => {
const userId = new mongoose.Types.ObjectId();
const authorId = new mongoose.Types.ObjectId();
const agentId = uuidv4();
const fileId = uuidv4();
// Create users
await User.create({
_id: userId,
email: 'user@example.com',
emailVerified: true,
provider: 'local',
role: 'EDITOR',
});
await User.create({
_id: authorId,
email: 'author@example.com',
emailVerified: true,
provider: 'local',
});
// Create file
await createFile({
file_id: fileId,
user: authorId,
filename: 'test.txt',
filepath: '/uploads/test.txt',
type: 'text/plain',
bytes: 100,
});
// Create agent
const agent = await createAgent({
id: agentId,
name: 'Test Agent',
author: authorId,
model: 'gpt-4',
provider: 'openai',
tool_resources: {
file_search: {
file_ids: [fileId],
},
},
});
// Grant permission to EDITOR role only
await grantPermission({
principalType: PrincipalType.ROLE,
principalId: 'EDITOR',
resourceType: ResourceType.AGENT,
resourceId: agent._id,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
grantedBy: authorId,
});
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
// Check with EDITOR role - should have access
const accessAsEditor = await hasAccessToFilesViaAgent({
userId: userId,
role: 'EDITOR',
fileIds: [fileId],
agentId: agent.id,
});
expect(accessAsEditor.get(fileId)).toBe(true);
// Simulate role change to USER - should lose access
const accessAsUser = await hasAccessToFilesViaAgent({
userId: userId,
role: SystemRoles.USER,
fileIds: [fileId],
agentId: agent.id,
});
expect(accessAsUser.get(fileId)).toBe(false);
});
});
});

View file

@ -1,372 +0,0 @@
const { z } = require('zod');
const { logger } = require('@librechat/data-schemas');
const { createTempChatExpirationDate } = require('@librechat/api');
const { Message } = require('~/db/models');
const idSchema = z.string().uuid();
/**
* Saves a message in the database.
*
* @async
* @function saveMessage
* @param {ServerRequest} req - The request object containing user information.
* @param {Object} params - The message data object.
* @param {string} params.endpoint - The endpoint where the message originated.
* @param {string} params.iconURL - The URL of the sender's icon.
* @param {string} params.messageId - The unique identifier for the message.
* @param {string} params.newMessageId - The new unique identifier for the message (if applicable).
* @param {string} params.conversationId - The identifier of the conversation.
* @param {string} [params.parentMessageId] - The identifier of the parent message, if any.
* @param {string} params.sender - The identifier of the sender.
* @param {string} params.text - The text content of the message.
* @param {boolean} params.isCreatedByUser - Indicates if the message was created by the user.
* @param {string} [params.error] - Any error associated with the message.
* @param {boolean} [params.unfinished] - Indicates if the message is unfinished.
* @param {Object[]} [params.files] - An array of files associated with the message.
* @param {string} [params.finish_reason] - Reason for finishing the message.
* @param {number} [params.tokenCount] - The number of tokens in the message.
* @param {string} [params.plugin] - Plugin associated with the message.
* @param {string[]} [params.plugins] - An array of plugins associated with the message.
* @param {string} [params.model] - The model used to generate the message.
* @param {Object} [metadata] - Additional metadata for this operation
* @param {string} [metadata.context] - The context of the operation
* @returns {Promise<TMessage>} The updated or newly inserted message document.
* @throws {Error} If there is an error in saving the message.
*/
async function saveMessage(req, params, metadata) {
if (!req?.user?.id) {
throw new Error('User not authenticated');
}
const validConvoId = idSchema.safeParse(params.conversationId);
if (!validConvoId.success) {
logger.warn(`Invalid conversation ID: ${params.conversationId}`);
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
logger.info(`---Invalid conversation ID Params: ${JSON.stringify(params, null, 2)}`);
return;
}
try {
const update = {
...params,
user: req.user.id,
messageId: params.newMessageId || params.messageId,
};
if (req?.body?.isTemporary) {
try {
const appConfig = req.config;
update.expiredAt = createTempChatExpirationDate(appConfig?.interfaceConfig);
} catch (err) {
logger.error('Error creating temporary chat expiration date:', err);
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
update.expiredAt = null;
}
} else {
update.expiredAt = null;
}
if (update.tokenCount != null && isNaN(update.tokenCount)) {
logger.warn(
`Resetting invalid \`tokenCount\` for message \`${params.messageId}\`: ${update.tokenCount}`,
);
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
update.tokenCount = 0;
}
const message = await Message.findOneAndUpdate(
{ messageId: params.messageId, user: req.user.id },
update,
{ upsert: true, new: true },
);
return message.toObject();
} catch (err) {
logger.error('Error saving message:', err);
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
// Check if this is a duplicate key error (MongoDB error code 11000)
if (err.code === 11000 && err.message.includes('duplicate key error')) {
// Log the duplicate key error but don't crash the application
logger.warn(`Duplicate messageId detected: ${params.messageId}. Continuing execution.`);
try {
// Try to find the existing message with this ID
const existingMessage = await Message.findOne({
messageId: params.messageId,
user: req.user.id,
});
// If we found it, return it
if (existingMessage) {
return existingMessage.toObject();
}
// If we can't find it (unlikely but possible in race conditions)
return {
...params,
messageId: params.messageId,
user: req.user.id,
};
} catch (findError) {
// If the findOne also fails, log it but don't crash
logger.warn(
`Could not retrieve existing message with ID ${params.messageId}: ${findError.message}`,
);
return {
...params,
messageId: params.messageId,
user: req.user.id,
};
}
}
throw err; // Re-throw other errors
}
}
/**
* Saves multiple messages in the database in bulk.
*
* @async
* @function bulkSaveMessages
* @param {Object[]} messages - An array of message objects to save.
* @param {boolean} [overrideTimestamp=false] - Indicates whether to override the timestamps of the messages. Defaults to false.
* @returns {Promise<Object>} The result of the bulk write operation.
* @throws {Error} If there is an error in saving messages in bulk.
*/
async function bulkSaveMessages(messages, overrideTimestamp = false) {
try {
const bulkOps = messages.map((message) => ({
updateOne: {
filter: { messageId: message.messageId },
update: message,
timestamps: !overrideTimestamp,
upsert: true,
},
}));
const result = await Message.bulkWrite(bulkOps);
return result;
} catch (err) {
logger.error('Error saving messages in bulk:', err);
throw err;
}
}
/**
* Records a message in the database.
*
* @async
* @function recordMessage
* @param {Object} params - The message data object.
* @param {string} params.user - The identifier of the user.
* @param {string} params.endpoint - The endpoint where the message originated.
* @param {string} params.messageId - The unique identifier for the message.
* @param {string} params.conversationId - The identifier of the conversation.
* @param {string} [params.parentMessageId] - The identifier of the parent message, if any.
* @param {Partial<TMessage>} rest - Any additional properties from the TMessage typedef not explicitly listed.
* @returns {Promise<Object>} The updated or newly inserted message document.
* @throws {Error} If there is an error in saving the message.
*/
async function recordMessage({
user,
endpoint,
messageId,
conversationId,
parentMessageId,
...rest
}) {
try {
// No parsing of convoId as may use threadId
const message = {
user,
endpoint,
messageId,
conversationId,
parentMessageId,
...rest,
};
return await Message.findOneAndUpdate({ user, messageId }, message, {
upsert: true,
new: true,
});
} catch (err) {
logger.error('Error recording message:', err);
throw err;
}
}
/**
* Updates the text of a message.
*
* @async
* @function updateMessageText
* @param {Object} params - The update data object.
* @param {Object} req - The request object.
* @param {string} params.messageId - The unique identifier for the message.
* @param {string} params.text - The new text content of the message.
* @returns {Promise<void>}
* @throws {Error} If there is an error in updating the message text.
*/
async function updateMessageText(req, { messageId, text }) {
try {
await Message.updateOne({ messageId, user: req.user.id }, { text });
} catch (err) {
logger.error('Error updating message text:', err);
throw err;
}
}
/**
* Updates a message.
*
* @async
* @function updateMessage
* @param {Object} req - The request object.
* @param {Object} message - The message object containing update data.
* @param {string} message.messageId - The unique identifier for the message.
* @param {string} [message.text] - The new text content of the message.
* @param {Object[]} [message.files] - The files associated with the message.
* @param {boolean} [message.isCreatedByUser] - Indicates if the message was created by the user.
* @param {string} [message.sender] - The identifier of the sender.
* @param {number} [message.tokenCount] - The number of tokens in the message.
* @param {Object} [metadata] - The operation metadata
* @param {string} [metadata.context] - The operation metadata
* @returns {Promise<TMessage>} The updated message document.
* @throws {Error} If there is an error in updating the message or if the message is not found.
*/
async function updateMessage(req, message, metadata) {
try {
const { messageId, ...update } = message;
const updatedMessage = await Message.findOneAndUpdate(
{ messageId, user: req.user.id },
update,
{
new: true,
},
);
if (!updatedMessage) {
throw new Error('Message not found or user not authorized.');
}
return {
messageId: updatedMessage.messageId,
conversationId: updatedMessage.conversationId,
parentMessageId: updatedMessage.parentMessageId,
sender: updatedMessage.sender,
text: updatedMessage.text,
isCreatedByUser: updatedMessage.isCreatedByUser,
tokenCount: updatedMessage.tokenCount,
feedback: updatedMessage.feedback,
};
} catch (err) {
logger.error('Error updating message:', err);
if (metadata && metadata?.context) {
logger.info(`---\`updateMessage\` context: ${metadata.context}`);
}
throw err;
}
}
/**
* Deletes messages in a conversation since a specific message.
*
* @async
* @function deleteMessagesSince
* @param {Object} params - The parameters object.
* @param {Object} req - The request object.
* @param {string} params.messageId - The unique identifier for the message.
* @param {string} params.conversationId - The identifier of the conversation.
* @returns {Promise<Number>} The number of deleted messages.
* @throws {Error} If there is an error in deleting messages.
*/
async function deleteMessagesSince(req, { messageId, conversationId }) {
try {
const message = await Message.findOne({ messageId, user: req.user.id }).lean();
if (message) {
const query = Message.find({ conversationId, user: req.user.id });
return await query.deleteMany({
createdAt: { $gt: message.createdAt },
});
}
return undefined;
} catch (err) {
logger.error('Error deleting messages:', err);
throw err;
}
}
/**
* Retrieves messages from the database.
* @async
* @function getMessages
* @param {Record<string, unknown>} filter - The filter criteria.
* @param {string | undefined} [select] - The fields to select.
* @returns {Promise<TMessage[]>} The messages that match the filter criteria.
* @throws {Error} If there is an error in retrieving messages.
*/
async function getMessages(filter, select) {
try {
if (select) {
return await Message.find(filter).select(select).sort({ createdAt: 1 }).lean();
}
return await Message.find(filter).sort({ createdAt: 1 }).lean();
} catch (err) {
logger.error('Error getting messages:', err);
throw err;
}
}
/**
* Retrieves a single message from the database.
* @async
* @function getMessage
* @param {{ user: string, messageId: string }} params - The search parameters
* @returns {Promise<TMessage | null>} The message that matches the criteria or null if not found
* @throws {Error} If there is an error in retrieving the message
*/
async function getMessage({ user, messageId }) {
try {
return await Message.findOne({
user,
messageId,
}).lean();
} catch (err) {
logger.error('Error getting message:', err);
throw err;
}
}
/**
* Deletes messages from the database.
*
* @async
* @function deleteMessages
* @param {import('mongoose').FilterQuery<import('mongoose').Document>} filter - The filter criteria to find messages to delete.
* @returns {Promise<import('mongoose').DeleteResult>} The metadata with count of deleted messages.
* @throws {Error} If there is an error in deleting messages.
*/
async function deleteMessages(filter) {
try {
return await Message.deleteMany(filter);
} catch (err) {
logger.error('Error deleting messages:', err);
throw err;
}
}
module.exports = {
saveMessage,
bulkSaveMessages,
recordMessage,
updateMessageText,
updateMessage,
deleteMessagesSince,
getMessages,
getMessage,
deleteMessages,
};

View file

@ -1,82 +0,0 @@
const { logger } = require('@librechat/data-schemas');
const { Preset } = require('~/db/models');
const getPreset = async (user, presetId) => {
try {
return await Preset.findOne({ user, presetId }).lean();
} catch (error) {
logger.error('[getPreset] Error getting single preset', error);
return { message: 'Error getting single preset' };
}
};
module.exports = {
getPreset,
getPresets: async (user, filter) => {
try {
const presets = await Preset.find({ ...filter, user }).lean();
const defaultValue = 10000;
presets.sort((a, b) => {
let orderA = a.order !== undefined ? a.order : defaultValue;
let orderB = b.order !== undefined ? b.order : defaultValue;
if (orderA !== orderB) {
return orderA - orderB;
}
return b.updatedAt - a.updatedAt;
});
return presets;
} catch (error) {
logger.error('[getPresets] Error getting presets', error);
return { message: 'Error retrieving presets' };
}
},
savePreset: async (user, { presetId, newPresetId, defaultPreset, ...preset }) => {
try {
const setter = { $set: {} };
const { user: _, ...cleanPreset } = preset;
const update = { presetId, ...cleanPreset };
if (preset.tools && Array.isArray(preset.tools)) {
update.tools =
preset.tools
.map((tool) => tool?.pluginKey ?? tool)
.filter((toolName) => typeof toolName === 'string') ?? [];
}
if (newPresetId) {
update.presetId = newPresetId;
}
if (defaultPreset) {
update.defaultPreset = defaultPreset;
update.order = 0;
const currentDefault = await Preset.findOne({ defaultPreset: true, user });
if (currentDefault && currentDefault.presetId !== presetId) {
await Preset.findByIdAndUpdate(currentDefault._id, {
$unset: { defaultPreset: '', order: '' },
});
}
} else if (defaultPreset === false) {
update.defaultPreset = undefined;
update.order = undefined;
setter['$unset'] = { defaultPreset: '', order: '' };
}
setter.$set = update;
return await Preset.findOneAndUpdate({ presetId, user }, setter, { new: true, upsert: true });
} catch (error) {
logger.error('[savePreset] Error saving preset', error);
return { message: 'Error saving preset' };
}
},
deletePresets: async (user, filter) => {
// let toRemove = await Preset.find({ ...filter, user }).select('presetId');
// const ids = toRemove.map((instance) => instance.presetId);
let deleteCount = await Preset.deleteMany({ ...filter, user });
return deleteCount;
},
};

View file

@ -1,133 +0,0 @@
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
const { Project } = require('~/db/models');
/**
* Retrieve a project by ID and convert the found project document to a plain object.
*
* @param {string} projectId - The ID of the project to find and return as a plain object.
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
* @returns {Promise<IMongoProject>} A plain object representing the project document, or `null` if no project is found.
*/
const getProjectById = async function (projectId, fieldsToSelect = null) {
const query = Project.findById(projectId);
if (fieldsToSelect) {
query.select(fieldsToSelect);
}
return await query.lean();
};
/**
* Retrieve a project by name and convert the found project document to a plain object.
* If the project with the given name doesn't exist and the name is "instance", create it and return the lean version.
*
* @param {string} projectName - The name of the project to find or create.
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
* @returns {Promise<IMongoProject>} A plain object representing the project document.
*/
const getProjectByName = async function (projectName, fieldsToSelect = null) {
const query = { name: projectName };
const update = { $setOnInsert: { name: projectName } };
const options = {
new: true,
upsert: projectName === GLOBAL_PROJECT_NAME,
lean: true,
select: fieldsToSelect,
};
return await Project.findOneAndUpdate(query, update, options);
};
/**
* Add an array of prompt group IDs to a project's promptGroupIds array, ensuring uniqueness.
*
* @param {string} projectId - The ID of the project to update.
* @param {string[]} promptGroupIds - The array of prompt group IDs to add to the project.
* @returns {Promise<IMongoProject>} The updated project document.
*/
const addGroupIdsToProject = async function (projectId, promptGroupIds) {
return await Project.findByIdAndUpdate(
projectId,
{ $addToSet: { promptGroupIds: { $each: promptGroupIds } } },
{ new: true },
);
};
/**
* Remove an array of prompt group IDs from a project's promptGroupIds array.
*
* @param {string} projectId - The ID of the project to update.
* @param {string[]} promptGroupIds - The array of prompt group IDs to remove from the project.
* @returns {Promise<IMongoProject>} The updated project document.
*/
const removeGroupIdsFromProject = async function (projectId, promptGroupIds) {
return await Project.findByIdAndUpdate(
projectId,
{ $pull: { promptGroupIds: { $in: promptGroupIds } } },
{ new: true },
);
};
/**
* Remove a prompt group ID from all projects.
*
* @param {string} promptGroupId - The ID of the prompt group to remove from projects.
* @returns {Promise<void>}
*/
const removeGroupFromAllProjects = async (promptGroupId) => {
await Project.updateMany({}, { $pull: { promptGroupIds: promptGroupId } });
};
/**
* Add an array of agent IDs to a project's agentIds array, ensuring uniqueness.
*
* @param {string} projectId - The ID of the project to update.
* @param {string[]} agentIds - The array of agent IDs to add to the project.
* @returns {Promise<IMongoProject>} The updated project document.
*/
const addAgentIdsToProject = async function (projectId, agentIds) {
return await Project.findByIdAndUpdate(
projectId,
{ $addToSet: { agentIds: { $each: agentIds } } },
{ new: true },
);
};
/**
* Remove an array of agent IDs from a project's agentIds array.
*
* @param {string} projectId - The ID of the project to update.
* @param {string[]} agentIds - The array of agent IDs to remove from the project.
* @returns {Promise<IMongoProject>} The updated project document.
*/
const removeAgentIdsFromProject = async function (projectId, agentIds) {
return await Project.findByIdAndUpdate(
projectId,
{ $pull: { agentIds: { $in: agentIds } } },
{ new: true },
);
};
/**
* Remove an agent ID from all projects.
*
* @param {string} agentId - The ID of the agent to remove from projects.
* @returns {Promise<void>}
*/
const removeAgentFromAllProjects = async (agentId) => {
await Project.updateMany({}, { $pull: { agentIds: agentId } });
};
module.exports = {
getProjectById,
getProjectByName,
/* prompts */
addGroupIdsToProject,
removeGroupIdsFromProject,
removeGroupFromAllProjects,
/* agents */
addAgentIdsToProject,
removeAgentIdsFromProject,
removeAgentFromAllProjects,
};

View file

@ -1,708 +0,0 @@
const { ObjectId } = require('mongodb');
const { escapeRegExp } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
Constants,
SystemRoles,
ResourceType,
SystemCategories,
} = require('librechat-data-provider');
const {
removeGroupFromAllProjects,
removeGroupIdsFromProject,
addGroupIdsToProject,
getProjectByName,
} = require('./Project');
const { removeAllPermissions } = require('~/server/services/PermissionService');
const { PromptGroup, Prompt, AclEntry } = require('~/db/models');
/**
* Create a pipeline for the aggregation to get prompt groups
* @param {Object} query
* @param {number} skip
* @param {number} limit
* @returns {[Object]} - The pipeline for the aggregation
*/
const createGroupPipeline = (query, skip, limit) => {
return [
{ $match: query },
{ $sort: { createdAt: -1 } },
{ $skip: skip },
{ $limit: limit },
{
$lookup: {
from: 'prompts',
localField: 'productionId',
foreignField: '_id',
as: 'productionPrompt',
},
},
{ $unwind: { path: '$productionPrompt', preserveNullAndEmptyArrays: true } },
{
$project: {
name: 1,
numberOfGenerations: 1,
oneliner: 1,
category: 1,
projectIds: 1,
productionId: 1,
author: 1,
authorName: 1,
createdAt: 1,
updatedAt: 1,
'productionPrompt.prompt': 1,
// 'productionPrompt._id': 1,
// 'productionPrompt.type': 1,
},
},
];
};
/**
* Create a pipeline for the aggregation to get all prompt groups
* @param {Object} query
* @param {Partial<MongoPromptGroup>} $project
* @returns {[Object]} - The pipeline for the aggregation
*/
const createAllGroupsPipeline = (
query,
$project = {
name: 1,
oneliner: 1,
category: 1,
author: 1,
authorName: 1,
createdAt: 1,
updatedAt: 1,
command: 1,
'productionPrompt.prompt': 1,
},
) => {
return [
{ $match: query },
{ $sort: { createdAt: -1 } },
{
$lookup: {
from: 'prompts',
localField: 'productionId',
foreignField: '_id',
as: 'productionPrompt',
},
},
{ $unwind: { path: '$productionPrompt', preserveNullAndEmptyArrays: true } },
{
$project,
},
];
};
/**
* Get all prompt groups with filters
* @param {ServerRequest} req
* @param {TPromptGroupsWithFilterRequest} filter
* @returns {Promise<PromptGroupListResponse>}
*/
const getAllPromptGroups = async (req, filter) => {
try {
const { name, ...query } = filter;
let searchShared = true;
let searchSharedOnly = false;
if (name) {
query.name = new RegExp(escapeRegExp(name), 'i');
}
if (!query.category) {
delete query.category;
} else if (query.category === SystemCategories.MY_PROMPTS) {
searchShared = false;
delete query.category;
} else if (query.category === SystemCategories.NO_CATEGORY) {
query.category = '';
} else if (query.category === SystemCategories.SHARED_PROMPTS) {
searchSharedOnly = true;
delete query.category;
}
let combinedQuery = query;
if (searchShared) {
const project = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, 'promptGroupIds');
if (project && project.promptGroupIds && project.promptGroupIds.length > 0) {
const projectQuery = { _id: { $in: project.promptGroupIds }, ...query };
delete projectQuery.author;
combinedQuery = searchSharedOnly ? projectQuery : { $or: [projectQuery, query] };
}
}
const promptGroupsPipeline = createAllGroupsPipeline(combinedQuery);
return await PromptGroup.aggregate(promptGroupsPipeline).exec();
} catch (error) {
console.error('Error getting all prompt groups', error);
return { message: 'Error getting all prompt groups' };
}
};
/**
* Get prompt groups with filters
* @param {ServerRequest} req
* @param {TPromptGroupsWithFilterRequest} filter
* @returns {Promise<PromptGroupListResponse>}
*/
const getPromptGroups = async (req, filter) => {
try {
const { pageNumber = 1, pageSize = 10, name, ...query } = filter;
const validatedPageNumber = Math.max(parseInt(pageNumber, 10), 1);
const validatedPageSize = Math.max(parseInt(pageSize, 10), 1);
let searchShared = true;
let searchSharedOnly = false;
if (name) {
query.name = new RegExp(escapeRegExp(name), 'i');
}
if (!query.category) {
delete query.category;
} else if (query.category === SystemCategories.MY_PROMPTS) {
searchShared = false;
delete query.category;
} else if (query.category === SystemCategories.NO_CATEGORY) {
query.category = '';
} else if (query.category === SystemCategories.SHARED_PROMPTS) {
searchSharedOnly = true;
delete query.category;
}
let combinedQuery = query;
if (searchShared) {
// const projects = req.user.projects || []; // TODO: handle multiple projects
const project = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, 'promptGroupIds');
if (project && project.promptGroupIds && project.promptGroupIds.length > 0) {
const projectQuery = { _id: { $in: project.promptGroupIds }, ...query };
delete projectQuery.author;
combinedQuery = searchSharedOnly ? projectQuery : { $or: [projectQuery, query] };
}
}
const skip = (validatedPageNumber - 1) * validatedPageSize;
const limit = validatedPageSize;
const promptGroupsPipeline = createGroupPipeline(combinedQuery, skip, limit);
const totalPromptGroupsPipeline = [{ $match: combinedQuery }, { $count: 'total' }];
const [promptGroupsResults, totalPromptGroupsResults] = await Promise.all([
PromptGroup.aggregate(promptGroupsPipeline).exec(),
PromptGroup.aggregate(totalPromptGroupsPipeline).exec(),
]);
const promptGroups = promptGroupsResults;
const totalPromptGroups =
totalPromptGroupsResults.length > 0 ? totalPromptGroupsResults[0].total : 0;
return {
promptGroups,
pageNumber: validatedPageNumber.toString(),
pageSize: validatedPageSize.toString(),
pages: Math.ceil(totalPromptGroups / validatedPageSize).toString(),
};
} catch (error) {
console.error('Error getting prompt groups', error);
return { message: 'Error getting prompt groups' };
}
};
/**
* @param {Object} fields
* @param {string} fields._id
* @param {string} fields.author
* @param {string} fields.role
* @returns {Promise<TDeletePromptGroupResponse>}
*/
const deletePromptGroup = async ({ _id, author, role }) => {
// Build query - with ACL, author is optional
const query = { _id };
const groupQuery = { groupId: new ObjectId(_id) };
// Legacy: Add author filter if provided (backward compatibility)
if (author && role !== SystemRoles.ADMIN) {
query.author = author;
groupQuery.author = author;
}
const response = await PromptGroup.deleteOne(query);
if (!response || response.deletedCount === 0) {
throw new Error('Prompt group not found');
}
await Prompt.deleteMany(groupQuery);
await removeGroupFromAllProjects(_id);
try {
await removeAllPermissions({ resourceType: ResourceType.PROMPTGROUP, resourceId: _id });
} catch (error) {
logger.error('Error removing promptGroup permissions:', error);
}
return { message: 'Prompt group deleted successfully' };
};
/**
* Get prompt groups by accessible IDs with optional cursor-based pagination.
* @param {Object} params - The parameters for getting accessible prompt groups.
* @param {Array} [params.accessibleIds] - Array of prompt group ObjectIds the user has ACL access to.
* @param {Object} [params.otherParams] - Additional query parameters (including author filter).
* @param {number} [params.limit] - Number of prompt groups to return (max 100). If not provided, returns all prompt groups.
* @param {string} [params.after] - Cursor for pagination - get prompt groups after this cursor. // base64 encoded JSON string with updatedAt and _id.
* @returns {Promise<Object>} A promise that resolves to an object containing the prompt groups data and pagination info.
*/
async function getListPromptGroupsByAccess({
accessibleIds = [],
otherParams = {},
limit = null,
after = null,
}) {
const isPaginated = limit !== null && limit !== undefined;
const normalizedLimit = isPaginated ? Math.min(Math.max(1, parseInt(limit) || 20), 100) : null;
// Build base query combining ACL accessible prompt groups with other filters
const baseQuery = { ...otherParams, _id: { $in: accessibleIds } };
// Add cursor condition
if (after && typeof after === 'string' && after !== 'undefined' && after !== 'null') {
try {
const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8'));
const { updatedAt, _id } = cursor;
const cursorCondition = {
$or: [
{ updatedAt: { $lt: new Date(updatedAt) } },
{ updatedAt: new Date(updatedAt), _id: { $gt: new ObjectId(_id) } },
],
};
// Merge cursor condition with base query
if (Object.keys(baseQuery).length > 0) {
baseQuery.$and = [{ ...baseQuery }, cursorCondition];
// Remove the original conditions from baseQuery to avoid duplication
Object.keys(baseQuery).forEach((key) => {
if (key !== '$and') delete baseQuery[key];
});
} else {
Object.assign(baseQuery, cursorCondition);
}
} catch (error) {
logger.warn('Invalid cursor:', error.message);
}
}
// Build aggregation pipeline
const pipeline = [{ $match: baseQuery }, { $sort: { updatedAt: -1, _id: 1 } }];
// Only apply limit if pagination is requested
if (isPaginated) {
pipeline.push({ $limit: normalizedLimit + 1 });
}
// Add lookup for production prompt
pipeline.push(
{
$lookup: {
from: 'prompts',
localField: 'productionId',
foreignField: '_id',
as: 'productionPrompt',
},
},
{ $unwind: { path: '$productionPrompt', preserveNullAndEmptyArrays: true } },
{
$project: {
name: 1,
numberOfGenerations: 1,
oneliner: 1,
category: 1,
projectIds: 1,
productionId: 1,
author: 1,
authorName: 1,
createdAt: 1,
updatedAt: 1,
'productionPrompt.prompt': 1,
},
},
);
const promptGroups = await PromptGroup.aggregate(pipeline).exec();
const hasMore = isPaginated ? promptGroups.length > normalizedLimit : false;
const data = (isPaginated ? promptGroups.slice(0, normalizedLimit) : promptGroups).map(
(group) => {
if (group.author) {
group.author = group.author.toString();
}
return group;
},
);
// Generate next cursor only if paginated
let nextCursor = null;
if (isPaginated && hasMore && data.length > 0) {
const lastGroup = promptGroups[normalizedLimit - 1];
nextCursor = Buffer.from(
JSON.stringify({
updatedAt: lastGroup.updatedAt.toISOString(),
_id: lastGroup._id.toString(),
}),
).toString('base64');
}
return {
object: 'list',
data,
first_id: data.length > 0 ? data[0]._id.toString() : null,
last_id: data.length > 0 ? data[data.length - 1]._id.toString() : null,
has_more: hasMore,
after: nextCursor,
};
}
module.exports = {
getPromptGroups,
deletePromptGroup,
getAllPromptGroups,
getListPromptGroupsByAccess,
/**
* Create a prompt and its respective group
* @param {TCreatePromptRecord} saveData
* @returns {Promise<TCreatePromptResponse>}
*/
createPromptGroup: async (saveData) => {
try {
const { prompt, group, author, authorName } = saveData;
let newPromptGroup = await PromptGroup.findOneAndUpdate(
{ ...group, author, authorName, productionId: null },
{ $setOnInsert: { ...group, author, authorName, productionId: null } },
{ new: true, upsert: true },
)
.lean()
.select('-__v')
.exec();
const newPrompt = await Prompt.findOneAndUpdate(
{ ...prompt, author, groupId: newPromptGroup._id },
{ $setOnInsert: { ...prompt, author, groupId: newPromptGroup._id } },
{ new: true, upsert: true },
)
.lean()
.select('-__v')
.exec();
newPromptGroup = await PromptGroup.findByIdAndUpdate(
newPromptGroup._id,
{ productionId: newPrompt._id },
{ new: true },
)
.lean()
.select('-__v')
.exec();
return {
prompt: newPrompt,
group: {
...newPromptGroup,
productionPrompt: { prompt: newPrompt.prompt },
},
};
} catch (error) {
logger.error('Error saving prompt group', error);
throw new Error('Error saving prompt group');
}
},
/**
* Save a prompt
* @param {TCreatePromptRecord} saveData
* @returns {Promise<TCreatePromptResponse>}
*/
savePrompt: async (saveData) => {
try {
const { prompt, author } = saveData;
const newPromptData = {
...prompt,
author,
};
/** @type {TPrompt} */
let newPrompt;
try {
newPrompt = await Prompt.create(newPromptData);
} catch (error) {
if (error?.message?.includes('groupId_1_version_1')) {
await Prompt.db.collection('prompts').dropIndex('groupId_1_version_1');
} else {
throw error;
}
newPrompt = await Prompt.create(newPromptData);
}
return { prompt: newPrompt };
} catch (error) {
logger.error('Error saving prompt', error);
return { message: 'Error saving prompt' };
}
},
getPrompts: async (filter) => {
try {
return await Prompt.find(filter).sort({ createdAt: -1 }).lean();
} catch (error) {
logger.error('Error getting prompts', error);
return { message: 'Error getting prompts' };
}
},
getPrompt: async (filter) => {
try {
if (filter.groupId) {
filter.groupId = new ObjectId(filter.groupId);
}
return await Prompt.findOne(filter).lean();
} catch (error) {
logger.error('Error getting prompt', error);
return { message: 'Error getting prompt' };
}
},
/**
* Get prompt groups with filters
* @param {TGetRandomPromptsRequest} filter
* @returns {Promise<TGetRandomPromptsResponse>}
*/
getRandomPromptGroups: async (filter) => {
try {
const result = await PromptGroup.aggregate([
{
$match: {
category: { $ne: '' },
},
},
{
$group: {
_id: '$category',
promptGroup: { $first: '$$ROOT' },
},
},
{
$replaceRoot: { newRoot: '$promptGroup' },
},
{
$sample: { size: +filter.limit + +filter.skip },
},
{
$skip: +filter.skip,
},
{
$limit: +filter.limit,
},
]);
return { prompts: result };
} catch (error) {
logger.error('Error getting prompt groups', error);
return { message: 'Error getting prompt groups' };
}
},
getPromptGroupsWithPrompts: async (filter) => {
try {
return await PromptGroup.findOne(filter)
.populate({
path: 'prompts',
select: '-_id -__v -user',
})
.select('-_id -__v -user')
.lean();
} catch (error) {
logger.error('Error getting prompt groups', error);
return { message: 'Error getting prompt groups' };
}
},
getPromptGroup: async (filter) => {
try {
return await PromptGroup.findOne(filter).lean();
} catch (error) {
logger.error('Error getting prompt group', error);
return { message: 'Error getting prompt group' };
}
},
/**
* Deletes a prompt and its corresponding prompt group if it is the last prompt in the group.
*
* @param {Object} options - The options for deleting the prompt.
* @param {ObjectId|string} options.promptId - The ID of the prompt to delete.
* @param {ObjectId|string} options.groupId - The ID of the prompt's group.
* @param {ObjectId|string} options.author - The ID of the prompt's author.
* @param {string} options.role - The role of the prompt's author.
* @return {Promise<TDeletePromptResponse>} An object containing the result of the deletion.
* If the prompt was deleted successfully, the object will have a property 'prompt' with the value 'Prompt deleted successfully'.
* If the prompt group was deleted successfully, the object will have a property 'promptGroup' with the message 'Prompt group deleted successfully' and id of the deleted group.
* If there was an error deleting the prompt, the object will have a property 'message' with the value 'Error deleting prompt'.
*/
deletePrompt: async ({ promptId, groupId, author, role }) => {
const query = { _id: promptId, groupId, author };
if (role === SystemRoles.ADMIN) {
delete query.author;
}
const { deletedCount } = await Prompt.deleteOne(query);
if (deletedCount === 0) {
throw new Error('Failed to delete the prompt');
}
const remainingPrompts = await Prompt.find({ groupId })
.select('_id')
.sort({ createdAt: 1 })
.lean();
if (remainingPrompts.length === 0) {
// Remove all ACL entries for the promptGroup when deleting the last prompt
try {
await removeAllPermissions({
resourceType: ResourceType.PROMPTGROUP,
resourceId: groupId,
});
} catch (error) {
logger.error('Error removing promptGroup permissions:', error);
}
await PromptGroup.deleteOne({ _id: groupId });
await removeGroupFromAllProjects(groupId);
return {
prompt: 'Prompt deleted successfully',
promptGroup: {
message: 'Prompt group deleted successfully',
id: groupId,
},
};
} else {
const promptGroup = await PromptGroup.findById(groupId).lean();
if (promptGroup.productionId.toString() === promptId.toString()) {
await PromptGroup.updateOne(
{ _id: groupId },
{ productionId: remainingPrompts[remainingPrompts.length - 1]._id },
);
}
return { prompt: 'Prompt deleted successfully' };
}
},
/**
* Delete all prompts and prompt groups created by a specific user.
* @param {ServerRequest} req - The server request object.
* @param {string} userId - The ID of the user whose prompts and prompt groups are to be deleted.
*/
deleteUserPrompts: async (req, userId) => {
try {
const promptGroups = await getAllPromptGroups(req, { author: new ObjectId(userId) });
if (promptGroups.length === 0) {
return;
}
const groupIds = promptGroups.map((group) => group._id);
for (const groupId of groupIds) {
await removeGroupFromAllProjects(groupId);
}
await AclEntry.deleteMany({
resourceType: ResourceType.PROMPTGROUP,
resourceId: { $in: groupIds },
});
await PromptGroup.deleteMany({ author: new ObjectId(userId) });
await Prompt.deleteMany({ author: new ObjectId(userId) });
} catch (error) {
logger.error('[deleteUserPrompts] General error:', error);
}
},
/**
* Update prompt group
* @param {Partial<MongoPromptGroup>} filter - Filter to find prompt group
* @param {Partial<MongoPromptGroup>} data - Data to update
* @returns {Promise<TUpdatePromptGroupResponse>}
*/
updatePromptGroup: async (filter, data) => {
try {
const updateOps = {};
if (data.removeProjectIds) {
for (const projectId of data.removeProjectIds) {
await removeGroupIdsFromProject(projectId, [filter._id]);
}
updateOps.$pull = { projectIds: { $in: data.removeProjectIds } };
delete data.removeProjectIds;
}
if (data.projectIds) {
for (const projectId of data.projectIds) {
await addGroupIdsToProject(projectId, [filter._id]);
}
updateOps.$addToSet = { projectIds: { $each: data.projectIds } };
delete data.projectIds;
}
const updateData = { ...data, ...updateOps };
const updatedDoc = await PromptGroup.findOneAndUpdate(filter, updateData, {
new: true,
upsert: false,
});
if (!updatedDoc) {
throw new Error('Prompt group not found');
}
return updatedDoc;
} catch (error) {
logger.error('Error updating prompt group', error);
return { message: 'Error updating prompt group' };
}
},
/**
* Function to make a prompt production based on its ID.
* @param {String} promptId - The ID of the prompt to make production.
* @returns {Object} The result of the production operation.
*/
makePromptProduction: async (promptId) => {
try {
const prompt = await Prompt.findById(promptId).lean();
if (!prompt) {
throw new Error('Prompt not found');
}
await PromptGroup.findByIdAndUpdate(
prompt.groupId,
{ productionId: prompt._id },
{ new: true },
)
.lean()
.exec();
return {
message: 'Prompt production made successfully',
};
} catch (error) {
logger.error('Error making prompt production', error);
return { message: 'Error making prompt production' };
}
},
updatePromptLabels: async (_id, labels) => {
try {
const response = await Prompt.updateOne({ _id }, { $set: { labels } });
if (response.matchedCount === 0) {
return { message: 'Prompt not found' };
}
return { message: 'Prompt labels updated successfully' };
} catch (error) {
logger.error('Error updating prompt labels', error);
return { message: 'Error updating prompt labels' };
}
},
};

View file

@ -1,564 +0,0 @@
const mongoose = require('mongoose');
const { ObjectId } = require('mongodb');
const { logger } = require('@librechat/data-schemas');
const { MongoMemoryServer } = require('mongodb-memory-server');
const {
SystemRoles,
ResourceType,
AccessRoleIds,
PrincipalType,
PermissionBits,
} = require('librechat-data-provider');
// Mock the config/connect module to prevent connection attempts during tests
jest.mock('../../config/connect', () => jest.fn().mockResolvedValue(true));
const dbModels = require('~/db/models');
// Disable console for tests
logger.silent = true;
let mongoServer;
let Prompt, PromptGroup, AclEntry, AccessRole, User, Group, Project;
let promptFns, permissionService;
let testUsers, testGroups, testRoles;
beforeAll(async () => {
// Set up MongoDB memory server
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
// Initialize models
Prompt = dbModels.Prompt;
PromptGroup = dbModels.PromptGroup;
AclEntry = dbModels.AclEntry;
AccessRole = dbModels.AccessRole;
User = dbModels.User;
Group = dbModels.Group;
Project = dbModels.Project;
promptFns = require('~/models/Prompt');
permissionService = require('~/server/services/PermissionService');
// Create test data
await setupTestData();
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
jest.clearAllMocks();
});
async function setupTestData() {
// Create access roles for promptGroups
testRoles = {
viewer: await AccessRole.create({
accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
name: 'Viewer',
description: 'Can view promptGroups',
resourceType: ResourceType.PROMPTGROUP,
permBits: PermissionBits.VIEW,
}),
editor: await AccessRole.create({
accessRoleId: AccessRoleIds.PROMPTGROUP_EDITOR,
name: 'Editor',
description: 'Can view and edit promptGroups',
resourceType: ResourceType.PROMPTGROUP,
permBits: PermissionBits.VIEW | PermissionBits.EDIT,
}),
owner: await AccessRole.create({
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
name: 'Owner',
description: 'Full control over promptGroups',
resourceType: ResourceType.PROMPTGROUP,
permBits:
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
}),
};
// Create test users
testUsers = {
owner: await User.create({
name: 'Prompt Owner',
email: 'owner@example.com',
role: SystemRoles.USER,
}),
editor: await User.create({
name: 'Prompt Editor',
email: 'editor@example.com',
role: SystemRoles.USER,
}),
viewer: await User.create({
name: 'Prompt Viewer',
email: 'viewer@example.com',
role: SystemRoles.USER,
}),
admin: await User.create({
name: 'Admin User',
email: 'admin@example.com',
role: SystemRoles.ADMIN,
}),
noAccess: await User.create({
name: 'No Access User',
email: 'noaccess@example.com',
role: SystemRoles.USER,
}),
};
// Create test groups
testGroups = {
editors: await Group.create({
name: 'Prompt Editors',
description: 'Group with editor access',
}),
viewers: await Group.create({
name: 'Prompt Viewers',
description: 'Group with viewer access',
}),
};
await Project.create({
name: 'Global',
description: 'Global project',
promptGroupIds: [],
});
}
describe('Prompt ACL Permissions', () => {
describe('Creating Prompts with Permissions', () => {
it('should grant owner permissions when creating a prompt', async () => {
// First create a group
const testGroup = await PromptGroup.create({
name: 'Test Group',
category: 'testing',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new mongoose.Types.ObjectId(),
});
const promptData = {
prompt: {
prompt: 'Test prompt content',
name: 'Test Prompt',
type: 'text',
groupId: testGroup._id,
},
author: testUsers.owner._id,
};
await promptFns.savePrompt(promptData);
// Manually grant permissions as would happen in the route
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testGroup._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
// Check ACL entry
const aclEntry = await AclEntry.findOne({
resourceType: ResourceType.PROMPTGROUP,
resourceId: testGroup._id,
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
});
expect(aclEntry).toBeTruthy();
expect(aclEntry.permBits).toBe(testRoles.owner.permBits);
});
});
describe('Accessing Prompts', () => {
let testPromptGroup;
beforeEach(async () => {
// Create a prompt group
testPromptGroup = await PromptGroup.create({
name: 'Test Group',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
});
// Create a prompt
await Prompt.create({
prompt: 'Test prompt for access control',
name: 'Access Test Prompt',
author: testUsers.owner._id,
groupId: testPromptGroup._id,
type: 'text',
});
// Grant owner permissions
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
});
afterEach(async () => {
await Prompt.deleteMany({});
await PromptGroup.deleteMany({});
await AclEntry.deleteMany({});
});
it('owner should have full access to their prompt', async () => {
const hasAccess = await permissionService.checkPermission({
userId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
requiredPermission: PermissionBits.VIEW,
});
expect(hasAccess).toBe(true);
const canEdit = await permissionService.checkPermission({
userId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
requiredPermission: PermissionBits.EDIT,
});
expect(canEdit).toBe(true);
});
it('user with viewer role should only have view access', async () => {
// Grant viewer permissions
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.viewer._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
grantedBy: testUsers.owner._id,
});
const canView = await permissionService.checkPermission({
userId: testUsers.viewer._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
requiredPermission: PermissionBits.VIEW,
});
const canEdit = await permissionService.checkPermission({
userId: testUsers.viewer._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
requiredPermission: PermissionBits.EDIT,
});
expect(canView).toBe(true);
expect(canEdit).toBe(false);
});
it('user without permissions should have no access', async () => {
const hasAccess = await permissionService.checkPermission({
userId: testUsers.noAccess._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
requiredPermission: PermissionBits.VIEW,
});
expect(hasAccess).toBe(false);
});
it('admin should have access regardless of permissions', async () => {
// Admin users should work through normal permission system
// The middleware layer handles admin bypass, not the permission service
const hasAccess = await permissionService.checkPermission({
userId: testUsers.admin._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
requiredPermission: PermissionBits.VIEW,
});
// Without explicit permissions, even admin won't have access at this layer
expect(hasAccess).toBe(false);
// The actual admin bypass happens in the middleware layer (`canAccessPromptViaGroup`/`canAccessPromptGroupResource`)
// which checks req.user.role === SystemRoles.ADMIN
});
});
describe('Group-based Access', () => {
let testPromptGroup;
beforeEach(async () => {
// Create a prompt group first
testPromptGroup = await PromptGroup.create({
name: 'Group Access Test Group',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
});
await Prompt.create({
prompt: 'Group access test prompt',
name: 'Group Test',
author: testUsers.owner._id,
groupId: testPromptGroup._id,
type: 'text',
});
// Add users to groups
await User.findByIdAndUpdate(testUsers.editor._id, {
$push: { groups: testGroups.editors._id },
});
await User.findByIdAndUpdate(testUsers.viewer._id, {
$push: { groups: testGroups.viewers._id },
});
});
afterEach(async () => {
await Prompt.deleteMany({});
await AclEntry.deleteMany({});
await User.updateMany({}, { $set: { groups: [] } });
});
it('group members should inherit group permissions', async () => {
// Create a prompt group
const testPromptGroup = await PromptGroup.create({
name: 'Group Test Group',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
});
const { addUserToGroup } = require('~/models');
await addUserToGroup(testUsers.editor._id, testGroups.editors._id);
const prompt = await promptFns.savePrompt({
author: testUsers.owner._id,
prompt: {
prompt: 'Group test prompt',
name: 'Group Test',
groupId: testPromptGroup._id,
type: 'text',
},
});
// Check if savePrompt returned an error
if (!prompt || !prompt.prompt) {
throw new Error(`Failed to save prompt: ${prompt?.message || 'Unknown error'}`);
}
// Grant edit permissions to the group
await permissionService.grantPermission({
principalType: PrincipalType.GROUP,
principalId: testGroups.editors._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_EDITOR,
grantedBy: testUsers.owner._id,
});
// Check if group member has access
const hasAccess = await permissionService.checkPermission({
userId: testUsers.editor._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
requiredPermission: PermissionBits.EDIT,
});
expect(hasAccess).toBe(true);
// Check that non-member doesn't have access
const nonMemberAccess = await permissionService.checkPermission({
userId: testUsers.viewer._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
requiredPermission: PermissionBits.EDIT,
});
expect(nonMemberAccess).toBe(false);
});
});
describe('Public Access', () => {
let publicPromptGroup, privatePromptGroup;
beforeEach(async () => {
// Create separate prompt groups for public and private access
publicPromptGroup = await PromptGroup.create({
name: 'Public Access Test Group',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
});
privatePromptGroup = await PromptGroup.create({
name: 'Private Access Test Group',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
});
// Create prompts in their respective groups
await Prompt.create({
prompt: 'Public prompt',
name: 'Public',
author: testUsers.owner._id,
groupId: publicPromptGroup._id,
type: 'text',
});
await Prompt.create({
prompt: 'Private prompt',
name: 'Private',
author: testUsers.owner._id,
groupId: privatePromptGroup._id,
type: 'text',
});
// Grant public view access to publicPromptGroup
await permissionService.grantPermission({
principalType: PrincipalType.PUBLIC,
principalId: null,
resourceType: ResourceType.PROMPTGROUP,
resourceId: publicPromptGroup._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
grantedBy: testUsers.owner._id,
});
// Grant only owner access to privatePromptGroup
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: privatePromptGroup._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
});
afterEach(async () => {
await Prompt.deleteMany({});
await PromptGroup.deleteMany({});
await AclEntry.deleteMany({});
});
it('public prompt should be accessible to any user', async () => {
const hasAccess = await permissionService.checkPermission({
userId: testUsers.noAccess._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: publicPromptGroup._id,
requiredPermission: PermissionBits.VIEW,
includePublic: true,
});
expect(hasAccess).toBe(true);
});
it('private prompt should not be accessible to unauthorized users', async () => {
const hasAccess = await permissionService.checkPermission({
userId: testUsers.noAccess._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: privatePromptGroup._id,
requiredPermission: PermissionBits.VIEW,
includePublic: true,
});
expect(hasAccess).toBe(false);
});
});
describe('Prompt Deletion', () => {
let testPromptGroup;
it('should remove ACL entries when prompt is deleted', async () => {
testPromptGroup = await PromptGroup.create({
name: 'Deletion Test Group',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
});
const prompt = await promptFns.savePrompt({
author: testUsers.owner._id,
prompt: {
prompt: 'To be deleted',
name: 'Delete Test',
groupId: testPromptGroup._id,
type: 'text',
},
});
// Check if savePrompt returned an error
if (!prompt || !prompt.prompt) {
throw new Error(`Failed to save prompt: ${prompt?.message || 'Unknown error'}`);
}
const testPromptId = prompt.prompt._id;
const promptGroupId = testPromptGroup._id;
// Grant permission
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
// Verify ACL entry exists
const beforeDelete = await AclEntry.find({
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
});
expect(beforeDelete).toHaveLength(1);
// Delete the prompt
await promptFns.deletePrompt({
promptId: testPromptId,
groupId: promptGroupId,
author: testUsers.owner._id,
role: SystemRoles.USER,
});
// Verify ACL entries are removed
const aclEntries = await AclEntry.find({
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
});
expect(aclEntries).toHaveLength(0);
});
});
describe('Backwards Compatibility', () => {
it('should handle prompts without ACL entries gracefully', async () => {
// Create a prompt group first
const promptGroup = await PromptGroup.create({
name: 'Legacy Test Group',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
});
// Create a prompt without ACL entries (legacy prompt)
const legacyPrompt = await Prompt.create({
prompt: 'Legacy prompt without ACL',
name: 'Legacy',
author: testUsers.owner._id,
groupId: promptGroup._id,
type: 'text',
});
// The system should handle this gracefully
const prompt = await promptFns.getPrompt({ _id: legacyPrompt._id });
expect(prompt).toBeTruthy();
expect(prompt._id.toString()).toBe(legacyPrompt._id.toString());
});
});
});

View file

@ -1,304 +0,0 @@
const {
CacheKeys,
SystemRoles,
roleDefaults,
permissionsSchema,
removeNullishValues,
} = require('librechat-data-provider');
const { logger } = require('@librechat/data-schemas');
const getLogStores = require('~/cache/getLogStores');
const { Role } = require('~/db/models');
/**
* Retrieve a role by name and convert the found role document to a plain object.
* If the role with the given name doesn't exist and the name is a system defined role,
* create it and return the lean version.
*
* @param {string} roleName - The name of the role to find or create.
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
* @returns {Promise<IRole>} Role document.
*/
const getRoleByName = async function (roleName, fieldsToSelect = null) {
const cache = getLogStores(CacheKeys.ROLES);
try {
const cachedRole = await cache.get(roleName);
if (cachedRole) {
return cachedRole;
}
let query = Role.findOne({ name: roleName });
if (fieldsToSelect) {
query = query.select(fieldsToSelect);
}
let role = await query.lean().exec();
if (!role && SystemRoles[roleName]) {
role = await new Role(roleDefaults[roleName]).save();
await cache.set(roleName, role);
return role.toObject();
}
await cache.set(roleName, role);
return role;
} catch (error) {
throw new Error(`Failed to retrieve or create role: ${error.message}`);
}
};
/**
* Update role values by name.
*
* @param {string} roleName - The name of the role to update.
* @param {Partial<TRole>} updates - The fields to update.
* @returns {Promise<TRole>} Updated role document.
*/
const updateRoleByName = async function (roleName, updates) {
const cache = getLogStores(CacheKeys.ROLES);
try {
const role = await Role.findOneAndUpdate(
{ name: roleName },
{ $set: updates },
{ new: true, lean: true },
)
.select('-__v')
.lean()
.exec();
await cache.set(roleName, role);
return role;
} catch (error) {
throw new Error(`Failed to update role: ${error.message}`);
}
};
/**
* Updates access permissions for a specific role and multiple permission types.
* @param {string} roleName - The role to update.
* @param {Object.<PermissionTypes, Object.<Permissions, boolean>>} permissionsUpdate - Permissions to update and their values.
* @param {IRole} [roleData] - Optional role data to use instead of fetching from the database.
*/
async function updateAccessPermissions(roleName, permissionsUpdate, roleData) {
// Filter and clean the permission updates based on our schema definition.
const updates = {};
for (const [permissionType, permissions] of Object.entries(permissionsUpdate)) {
if (permissionsSchema.shape && permissionsSchema.shape[permissionType]) {
updates[permissionType] = removeNullishValues(permissions);
}
}
if (!Object.keys(updates).length) {
return;
}
try {
const role = roleData ?? (await getRoleByName(roleName));
if (!role) {
return;
}
const currentPermissions = role.permissions || {};
const updatedPermissions = { ...currentPermissions };
let hasChanges = false;
const unsetFields = {};
const permissionTypes = Object.keys(permissionsSchema.shape || {});
for (const permType of permissionTypes) {
if (role[permType] && typeof role[permType] === 'object') {
logger.info(
`Migrating '${roleName}' role from old schema: found '${permType}' at top level`,
);
updatedPermissions[permType] = {
...updatedPermissions[permType],
...role[permType],
};
unsetFields[permType] = 1;
hasChanges = true;
}
}
// Migrate legacy SHARED_GLOBAL → SHARE for PROMPTS and AGENTS.
// SHARED_GLOBAL was removed in favour of SHARE in PR #11283. If the DB still has
// SHARED_GLOBAL but not SHARE, inherit the value so sharing intent is preserved.
const legacySharedGlobalTypes = ['PROMPTS', 'AGENTS'];
for (const legacyPermType of legacySharedGlobalTypes) {
const existingTypePerms = currentPermissions[legacyPermType];
if (
existingTypePerms &&
'SHARED_GLOBAL' in existingTypePerms &&
!('SHARE' in existingTypePerms) &&
updates[legacyPermType] &&
// Don't override an explicit SHARE value the caller already provided
!('SHARE' in updates[legacyPermType])
) {
const inheritedValue = existingTypePerms['SHARED_GLOBAL'];
updates[legacyPermType]['SHARE'] = inheritedValue;
logger.info(
`Migrating '${roleName}' role ${legacyPermType}.SHARED_GLOBAL=${inheritedValue} → SHARE`,
);
}
}
for (const [permissionType, permissions] of Object.entries(updates)) {
const currentTypePermissions = currentPermissions[permissionType] || {};
updatedPermissions[permissionType] = { ...currentTypePermissions };
for (const [permission, value] of Object.entries(permissions)) {
if (currentTypePermissions[permission] !== value) {
updatedPermissions[permissionType][permission] = value;
hasChanges = true;
logger.info(
`Updating '${roleName}' role permission '${permissionType}' '${permission}' from ${currentTypePermissions[permission]} to: ${value}`,
);
}
}
}
// Clean up orphaned SHARED_GLOBAL fields left in DB after the schema rename.
// Since we $set the full permissions object, deleting from updatedPermissions
// is sufficient to remove the field from MongoDB.
for (const legacyPermType of legacySharedGlobalTypes) {
const existingTypePerms = currentPermissions[legacyPermType];
if (existingTypePerms && 'SHARED_GLOBAL' in existingTypePerms) {
if (!updates[legacyPermType]) {
// permType wasn't in the update payload so the migration block above didn't run.
// Create a writable copy and handle the SHARED_GLOBAL → SHARE inheritance here
// to avoid removing SHARED_GLOBAL without writing SHARE (data loss).
updatedPermissions[legacyPermType] = { ...existingTypePerms };
if (!('SHARE' in existingTypePerms)) {
updatedPermissions[legacyPermType]['SHARE'] = existingTypePerms['SHARED_GLOBAL'];
logger.info(
`Migrating '${roleName}' role ${legacyPermType}.SHARED_GLOBAL=${existingTypePerms['SHARED_GLOBAL']} → SHARE`,
);
}
}
delete updatedPermissions[legacyPermType]['SHARED_GLOBAL'];
hasChanges = true;
logger.info(
`Removed legacy SHARED_GLOBAL field from '${roleName}' role ${legacyPermType} permissions`,
);
}
}
if (hasChanges) {
const updateObj = { permissions: updatedPermissions };
if (Object.keys(unsetFields).length > 0) {
logger.info(
`Unsetting old schema fields for '${roleName}' role: ${Object.keys(unsetFields).join(', ')}`,
);
try {
await Role.updateOne(
{ name: roleName },
{
$set: updateObj,
$unset: unsetFields,
},
);
const cache = getLogStores(CacheKeys.ROLES);
const updatedRole = await Role.findOne({ name: roleName }).select('-__v').lean().exec();
await cache.set(roleName, updatedRole);
logger.info(`Updated role '${roleName}' and removed old schema fields`);
} catch (updateError) {
logger.error(`Error during role migration update: ${updateError.message}`);
throw updateError;
}
} else {
// Standard update if no migration needed
await updateRoleByName(roleName, updateObj);
}
logger.info(`Updated '${roleName}' role permissions`);
} else {
logger.info(`No changes needed for '${roleName}' role permissions`);
}
} catch (error) {
logger.error(`Failed to update ${roleName} role permissions:`, error);
}
}
/**
* Migrates roles from old schema to new schema structure.
* This can be called directly to fix existing roles.
*
* @param {string} [roleName] - Optional specific role to migrate. If not provided, migrates all roles.
* @returns {Promise<number>} Number of roles migrated.
*/
const migrateRoleSchema = async function (roleName) {
try {
// Get roles to migrate
let roles;
if (roleName) {
const role = await Role.findOne({ name: roleName });
roles = role ? [role] : [];
} else {
roles = await Role.find({});
}
logger.info(`Migrating ${roles.length} roles to new schema structure`);
let migratedCount = 0;
for (const role of roles) {
const permissionTypes = Object.keys(permissionsSchema.shape || {});
const unsetFields = {};
let hasOldSchema = false;
// Check for old schema fields
for (const permType of permissionTypes) {
if (role[permType] && typeof role[permType] === 'object') {
hasOldSchema = true;
// Ensure permissions object exists
role.permissions = role.permissions || {};
// Migrate permissions from old location to new
role.permissions[permType] = {
...role.permissions[permType],
...role[permType],
};
// Mark field for removal
unsetFields[permType] = 1;
}
}
if (hasOldSchema) {
try {
logger.info(`Migrating role '${role.name}' from old schema structure`);
// Simple update operation
await Role.updateOne(
{ _id: role._id },
{
$set: { permissions: role.permissions },
$unset: unsetFields,
},
);
// Refresh cache
const cache = getLogStores(CacheKeys.ROLES);
const updatedRole = await Role.findById(role._id).lean().exec();
await cache.set(role.name, updatedRole);
migratedCount++;
logger.info(`Migrated role '${role.name}'`);
} catch (error) {
logger.error(`Failed to migrate role '${role.name}': ${error.message}`);
}
}
}
logger.info(`Migration complete: ${migratedCount} roles migrated`);
return migratedCount;
} catch (error) {
logger.error(`Role schema migration failed: ${error.message}`);
throw error;
}
};
module.exports = {
getRoleByName,
updateRoleByName,
migrateRoleSchema,
updateAccessPermissions,
};

View file

@ -1,511 +0,0 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const {
SystemRoles,
Permissions,
roleDefaults,
PermissionTypes,
} = require('librechat-data-provider');
const { getRoleByName, updateAccessPermissions } = require('~/models/Role');
const getLogStores = require('~/cache/getLogStores');
const { initializeRoles } = require('~/models');
const { Role } = require('~/db/models');
// Mock the cache
jest.mock('~/cache/getLogStores', () =>
jest.fn().mockReturnValue({
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
}),
);
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Role.deleteMany({});
getLogStores.mockClear();
});
describe('updateAccessPermissions', () => {
it('should update permissions when changes are needed', async () => {
await new Role({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARE: false,
},
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARE: true,
},
});
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: true,
SHARE: true,
});
});
it('should not update permissions when no changes are needed', async () => {
await new Role({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARE: false,
},
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARE: false,
},
});
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: true,
SHARE: false,
});
});
it('should handle non-existent roles', async () => {
await updateAccessPermissions('NON_EXISTENT_ROLE', {
[PermissionTypes.PROMPTS]: { CREATE: true },
});
const role = await Role.findOne({ name: 'NON_EXISTENT_ROLE' });
expect(role).toBeNull();
});
it('should update only specified permissions', async () => {
await new Role({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARE: false,
},
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { SHARE: true },
});
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: true,
SHARE: true,
});
});
it('should handle partial updates', async () => {
await new Role({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARE: false,
},
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { USE: false },
});
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: false,
SHARE: false,
});
});
it('should update multiple permission types at once', async () => {
await new Role({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARE: false },
[PermissionTypes.BOOKMARKS]: { USE: true },
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { USE: false, SHARE: true },
[PermissionTypes.BOOKMARKS]: { USE: false },
});
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: false,
SHARE: true,
});
expect(updatedRole.permissions[PermissionTypes.BOOKMARKS]).toEqual({ USE: false });
});
it('should handle updates for a single permission type', async () => {
await new Role({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARE: false },
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { USE: false, SHARE: true },
});
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: false,
SHARE: true,
});
});
it('should update MULTI_CONVO permissions', async () => {
await new Role({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.MULTI_CONVO]: { USE: false },
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.MULTI_CONVO]: { USE: true },
});
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]).toEqual({ USE: true });
});
it('should update MULTI_CONVO permissions along with other permission types', async () => {
await new Role({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARE: false },
[PermissionTypes.MULTI_CONVO]: { USE: false },
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { SHARE: true },
[PermissionTypes.MULTI_CONVO]: { USE: true },
});
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: true,
SHARE: true,
});
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]).toEqual({ USE: true });
});
it('should inherit SHARED_GLOBAL value into SHARE when SHARE is absent from both DB and update', async () => {
// Simulates the startup backfill path: caller sends SHARE_PUBLIC but not SHARE;
// migration should inherit SHARED_GLOBAL to preserve the deployment's sharing intent.
await Role.collection.insertOne({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: { USE: true, CREATE: true, SHARED_GLOBAL: true },
[PermissionTypes.AGENTS]: { USE: true, CREATE: true, SHARED_GLOBAL: false },
},
});
await updateAccessPermissions(SystemRoles.USER, {
// No explicit SHARE — migration should inherit from SHARED_GLOBAL
[PermissionTypes.PROMPTS]: { SHARE_PUBLIC: false },
[PermissionTypes.AGENTS]: { SHARE_PUBLIC: false },
});
const updatedRole = await getRoleByName(SystemRoles.USER);
// SHARED_GLOBAL=true → SHARE=true (inherited)
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARE).toBe(true);
// SHARED_GLOBAL=false → SHARE=false (inherited)
expect(updatedRole.permissions[PermissionTypes.AGENTS].SHARE).toBe(false);
// SHARED_GLOBAL cleaned up
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBeUndefined();
expect(updatedRole.permissions[PermissionTypes.AGENTS].SHARED_GLOBAL).toBeUndefined();
});
it('should respect explicit SHARE in update payload and not override it with SHARED_GLOBAL', async () => {
// Caller explicitly passes SHARE: false even though SHARED_GLOBAL=true in DB.
// The explicit intent must win; migration must not silently overwrite it.
await Role.collection.insertOne({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: { USE: true, SHARED_GLOBAL: true },
},
});
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { SHARE: false }, // explicit false — should be preserved
});
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARE).toBe(false);
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBeUndefined();
});
it('should migrate SHARED_GLOBAL to SHARE even when the permType is not in the update payload', async () => {
// Bug #2 regression: cleanup block removes SHARED_GLOBAL but migration block only
// runs when the permType is in the update payload. Without the fix, SHARE would be
// lost when any other permType (e.g. MULTI_CONVO) is the only thing being updated.
await Role.collection.insertOne({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: {
USE: true,
SHARED_GLOBAL: true, // legacy — NO SHARE present
},
[PermissionTypes.MULTI_CONVO]: { USE: false },
},
});
// Only update MULTI_CONVO — PROMPTS is intentionally absent from the payload
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.MULTI_CONVO]: { USE: true },
});
const updatedRole = await getRoleByName(SystemRoles.USER);
// SHARE should have been inherited from SHARED_GLOBAL, not silently dropped
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARE).toBe(true);
// SHARED_GLOBAL should be removed
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBeUndefined();
// Original USE should be untouched
expect(updatedRole.permissions[PermissionTypes.PROMPTS].USE).toBe(true);
// The actual update should have applied
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO].USE).toBe(true);
});
it('should remove orphaned SHARED_GLOBAL when SHARE already exists and permType is not in update', async () => {
// Safe cleanup case: SHARE already set, SHARED_GLOBAL is just orphaned noise.
// SHARE must not be changed; SHARED_GLOBAL must be removed.
await Role.collection.insertOne({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: {
USE: true,
SHARE: true, // already migrated
SHARED_GLOBAL: true, // orphaned
},
[PermissionTypes.MULTI_CONVO]: { USE: false },
},
});
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.MULTI_CONVO]: { USE: true },
});
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBeUndefined();
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARE).toBe(true);
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO].USE).toBe(true);
});
it('should not update MULTI_CONVO permissions when no changes are needed', async () => {
await new Role({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.MULTI_CONVO]: { USE: true },
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.MULTI_CONVO]: { USE: true },
});
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]).toEqual({ USE: true });
});
});
describe('initializeRoles', () => {
beforeEach(async () => {
await Role.deleteMany({});
});
it('should create default roles if they do not exist', async () => {
await initializeRoles();
const adminRole = await getRoleByName(SystemRoles.ADMIN);
const userRole = await getRoleByName(SystemRoles.USER);
expect(adminRole).toBeTruthy();
expect(userRole).toBeTruthy();
// Check if all permission types exist in the permissions field
Object.values(PermissionTypes).forEach((permType) => {
expect(adminRole.permissions[permType]).toBeDefined();
expect(userRole.permissions[permType]).toBeDefined();
});
// Example: Check default values for ADMIN role
expect(adminRole.permissions[PermissionTypes.PROMPTS].SHARE).toBe(true);
expect(adminRole.permissions[PermissionTypes.BOOKMARKS].USE).toBe(true);
expect(adminRole.permissions[PermissionTypes.AGENTS].CREATE).toBe(true);
});
it('should not modify existing permissions for existing roles', async () => {
const customUserRole = {
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
},
};
await new Role(customUserRole).save();
await initializeRoles();
const userRole = await getRoleByName(SystemRoles.USER);
expect(userRole.permissions[PermissionTypes.PROMPTS]).toEqual(
customUserRole.permissions[PermissionTypes.PROMPTS],
);
expect(userRole.permissions[PermissionTypes.BOOKMARKS]).toEqual(
customUserRole.permissions[PermissionTypes.BOOKMARKS],
);
expect(userRole.permissions[PermissionTypes.AGENTS]).toBeDefined();
});
it('should add new permission types to existing roles', async () => {
const partialUserRole = {
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]:
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.PROMPTS],
[PermissionTypes.BOOKMARKS]:
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.BOOKMARKS],
},
};
await new Role(partialUserRole).save();
await initializeRoles();
const userRole = await getRoleByName(SystemRoles.USER);
expect(userRole.permissions[PermissionTypes.AGENTS]).toBeDefined();
expect(userRole.permissions[PermissionTypes.AGENTS].CREATE).toBeDefined();
expect(userRole.permissions[PermissionTypes.AGENTS].USE).toBeDefined();
expect(userRole.permissions[PermissionTypes.AGENTS].SHARE).toBeDefined();
});
it('should handle multiple runs without duplicating or modifying data', async () => {
await initializeRoles();
await initializeRoles();
const adminRoles = await Role.find({ name: SystemRoles.ADMIN });
const userRoles = await Role.find({ name: SystemRoles.USER });
expect(adminRoles).toHaveLength(1);
expect(userRoles).toHaveLength(1);
const adminPerms = adminRoles[0].toObject().permissions;
const userPerms = userRoles[0].toObject().permissions;
Object.values(PermissionTypes).forEach((permType) => {
expect(adminPerms[permType]).toBeDefined();
expect(userPerms[permType]).toBeDefined();
});
});
it('should update roles with missing permission types from roleDefaults', async () => {
const partialAdminRole = {
name: SystemRoles.ADMIN,
permissions: {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: false,
[Permissions.SHARE]: false,
},
[PermissionTypes.BOOKMARKS]:
roleDefaults[SystemRoles.ADMIN].permissions[PermissionTypes.BOOKMARKS],
},
};
await new Role(partialAdminRole).save();
await initializeRoles();
const adminRole = await getRoleByName(SystemRoles.ADMIN);
expect(adminRole.permissions[PermissionTypes.PROMPTS]).toEqual(
partialAdminRole.permissions[PermissionTypes.PROMPTS],
);
expect(adminRole.permissions[PermissionTypes.AGENTS]).toBeDefined();
expect(adminRole.permissions[PermissionTypes.AGENTS].CREATE).toBeDefined();
expect(adminRole.permissions[PermissionTypes.AGENTS].USE).toBeDefined();
expect(adminRole.permissions[PermissionTypes.AGENTS].SHARE).toBeDefined();
});
it('should include MULTI_CONVO permissions when creating default roles', async () => {
await initializeRoles();
const adminRole = await getRoleByName(SystemRoles.ADMIN);
const userRole = await getRoleByName(SystemRoles.USER);
expect(adminRole.permissions[PermissionTypes.MULTI_CONVO]).toBeDefined();
expect(userRole.permissions[PermissionTypes.MULTI_CONVO]).toBeDefined();
expect(adminRole.permissions[PermissionTypes.MULTI_CONVO].USE).toBe(
roleDefaults[SystemRoles.ADMIN].permissions[PermissionTypes.MULTI_CONVO].USE,
);
expect(userRole.permissions[PermissionTypes.MULTI_CONVO].USE).toBe(
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.MULTI_CONVO].USE,
);
});
it('should add MULTI_CONVO permissions to existing roles without them', async () => {
const partialUserRole = {
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]:
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.PROMPTS],
[PermissionTypes.BOOKMARKS]:
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.BOOKMARKS],
},
};
await new Role(partialUserRole).save();
await initializeRoles();
const userRole = await getRoleByName(SystemRoles.USER);
expect(userRole.permissions[PermissionTypes.MULTI_CONVO]).toBeDefined();
expect(userRole.permissions[PermissionTypes.MULTI_CONVO].USE).toBeDefined();
});
});

View file

@ -1,96 +0,0 @@
const { ToolCall } = require('~/db/models');
/**
* Create a new tool call
* @param {IToolCallData} toolCallData - The tool call data
* @returns {Promise<IToolCallData>} The created tool call document
*/
async function createToolCall(toolCallData) {
try {
return await ToolCall.create(toolCallData);
} catch (error) {
throw new Error(`Error creating tool call: ${error.message}`);
}
}
/**
* Get a tool call by ID
* @param {string} id - The tool call document ID
* @returns {Promise<IToolCallData|null>} The tool call document or null if not found
*/
async function getToolCallById(id) {
try {
return await ToolCall.findById(id).lean();
} catch (error) {
throw new Error(`Error fetching tool call: ${error.message}`);
}
}
/**
* Get tool calls by message ID and user
* @param {string} messageId - The message ID
* @param {string} userId - The user's ObjectId
* @returns {Promise<Array>} Array of tool call documents
*/
async function getToolCallsByMessage(messageId, userId) {
try {
return await ToolCall.find({ messageId, user: userId }).lean();
} catch (error) {
throw new Error(`Error fetching tool calls: ${error.message}`);
}
}
/**
* Get tool calls by conversation ID and user
* @param {string} conversationId - The conversation ID
* @param {string} userId - The user's ObjectId
* @returns {Promise<IToolCallData[]>} Array of tool call documents
*/
async function getToolCallsByConvo(conversationId, userId) {
try {
return await ToolCall.find({ conversationId, user: userId }).lean();
} catch (error) {
throw new Error(`Error fetching tool calls: ${error.message}`);
}
}
/**
* Update a tool call
* @param {string} id - The tool call document ID
* @param {Partial<IToolCallData>} updateData - The data to update
* @returns {Promise<IToolCallData|null>} The updated tool call document or null if not found
*/
async function updateToolCall(id, updateData) {
try {
return await ToolCall.findByIdAndUpdate(id, updateData, { new: true }).lean();
} catch (error) {
throw new Error(`Error updating tool call: ${error.message}`);
}
}
/**
* Delete a tool call
* @param {string} userId - The related user's ObjectId
* @param {string} [conversationId] - The tool call conversation ID
* @returns {Promise<{ ok?: number; n?: number; deletedCount?: number }>} The result of the delete operation
*/
async function deleteToolCalls(userId, conversationId) {
try {
const query = { user: userId };
if (conversationId) {
query.conversationId = conversationId;
}
return await ToolCall.deleteMany(query);
} catch (error) {
throw new Error(`Error deleting tool call: ${error.message}`);
}
}
module.exports = {
createToolCall,
updateToolCall,
deleteToolCalls,
getToolCallById,
getToolCallsByConvo,
getToolCallsByMessage,
};

View file

@ -1,223 +0,0 @@
const { logger, CANCEL_RATE } = require('@librechat/data-schemas');
const { getMultiplier, getCacheMultiplier } = require('./tx');
const { Transaction } = require('~/db/models');
const { updateBalance } = require('~/models');
/** Method to calculate and set the tokenValue for a transaction */
function calculateTokenValue(txn) {
const { valueKey, tokenType, model, endpointTokenConfig, inputTokenCount } = txn;
const multiplier = Math.abs(
getMultiplier({ valueKey, tokenType, model, endpointTokenConfig, inputTokenCount }),
);
txn.rate = multiplier;
txn.tokenValue = txn.rawAmount * multiplier;
if (txn.context && txn.tokenType === 'completion' && txn.context === 'incomplete') {
txn.tokenValue = Math.ceil(txn.tokenValue * CANCEL_RATE);
txn.rate *= CANCEL_RATE;
}
}
/**
* New static method to create an auto-refill transaction that does NOT trigger a balance update.
* @param {object} txData - Transaction data.
* @param {string} txData.user - The user ID.
* @param {string} txData.tokenType - The type of token.
* @param {string} txData.context - The context of the transaction.
* @param {number} txData.rawAmount - The raw amount of tokens.
* @returns {Promise<object>} - The created transaction.
*/
async function createAutoRefillTransaction(txData) {
if (txData.rawAmount != null && isNaN(txData.rawAmount)) {
return;
}
const transaction = new Transaction(txData);
transaction.endpointTokenConfig = txData.endpointTokenConfig;
transaction.inputTokenCount = txData.inputTokenCount;
calculateTokenValue(transaction);
await transaction.save();
const balanceResponse = await updateBalance({
user: transaction.user,
incrementValue: txData.rawAmount,
setValues: { lastRefill: new Date() },
});
const result = {
rate: transaction.rate,
user: transaction.user.toString(),
balance: balanceResponse.tokenCredits,
};
logger.debug('[Balance.check] Auto-refill performed', result);
result.transaction = transaction;
return result;
}
/**
* Static method to create a transaction and update the balance
* @param {txData} _txData - Transaction data.
*/
async function createTransaction(_txData) {
const { balance, transactions, ...txData } = _txData;
if (txData.rawAmount != null && isNaN(txData.rawAmount)) {
return;
}
if (transactions?.enabled === false) {
return;
}
const transaction = new Transaction(txData);
transaction.endpointTokenConfig = txData.endpointTokenConfig;
transaction.inputTokenCount = txData.inputTokenCount;
calculateTokenValue(transaction);
await transaction.save();
if (!balance?.enabled) {
return;
}
let incrementValue = transaction.tokenValue;
const balanceResponse = await updateBalance({
user: transaction.user,
incrementValue,
});
return {
rate: transaction.rate,
user: transaction.user.toString(),
balance: balanceResponse.tokenCredits,
[transaction.tokenType]: incrementValue,
};
}
/**
* Static method to create a structured transaction and update the balance
* @param {txData} _txData - Transaction data.
*/
async function createStructuredTransaction(_txData) {
const { balance, transactions, ...txData } = _txData;
if (transactions?.enabled === false) {
return;
}
const transaction = new Transaction(txData);
transaction.endpointTokenConfig = txData.endpointTokenConfig;
transaction.inputTokenCount = txData.inputTokenCount;
calculateStructuredTokenValue(transaction);
await transaction.save();
if (!balance?.enabled) {
return;
}
let incrementValue = transaction.tokenValue;
const balanceResponse = await updateBalance({
user: transaction.user,
incrementValue,
});
return {
rate: transaction.rate,
user: transaction.user.toString(),
balance: balanceResponse.tokenCredits,
[transaction.tokenType]: incrementValue,
};
}
/** Method to calculate token value for structured tokens */
function calculateStructuredTokenValue(txn) {
if (!txn.tokenType) {
txn.tokenValue = txn.rawAmount;
return;
}
const { model, endpointTokenConfig, inputTokenCount } = txn;
if (txn.tokenType === 'prompt') {
const inputMultiplier = getMultiplier({
tokenType: 'prompt',
model,
endpointTokenConfig,
inputTokenCount,
});
const writeMultiplier =
getCacheMultiplier({ cacheType: 'write', model, endpointTokenConfig }) ?? inputMultiplier;
const readMultiplier =
getCacheMultiplier({ cacheType: 'read', model, endpointTokenConfig }) ?? inputMultiplier;
txn.rateDetail = {
input: inputMultiplier,
write: writeMultiplier,
read: readMultiplier,
};
const totalPromptTokens =
Math.abs(txn.inputTokens || 0) +
Math.abs(txn.writeTokens || 0) +
Math.abs(txn.readTokens || 0);
if (totalPromptTokens > 0) {
txn.rate =
(Math.abs(inputMultiplier * (txn.inputTokens || 0)) +
Math.abs(writeMultiplier * (txn.writeTokens || 0)) +
Math.abs(readMultiplier * (txn.readTokens || 0))) /
totalPromptTokens;
} else {
txn.rate = Math.abs(inputMultiplier); // Default to input rate if no tokens
}
txn.tokenValue = -(
Math.abs(txn.inputTokens || 0) * inputMultiplier +
Math.abs(txn.writeTokens || 0) * writeMultiplier +
Math.abs(txn.readTokens || 0) * readMultiplier
);
txn.rawAmount = -totalPromptTokens;
} else if (txn.tokenType === 'completion') {
const multiplier = getMultiplier({
tokenType: txn.tokenType,
model,
endpointTokenConfig,
inputTokenCount,
});
txn.rate = Math.abs(multiplier);
txn.tokenValue = -Math.abs(txn.rawAmount) * multiplier;
txn.rawAmount = -Math.abs(txn.rawAmount);
}
if (txn.context && txn.tokenType === 'completion' && txn.context === 'incomplete') {
txn.tokenValue = Math.ceil(txn.tokenValue * CANCEL_RATE);
txn.rate *= CANCEL_RATE;
if (txn.rateDetail) {
txn.rateDetail = Object.fromEntries(
Object.entries(txn.rateDetail).map(([k, v]) => [k, v * CANCEL_RATE]),
);
}
}
}
/**
* Queries and retrieves transactions based on a given filter.
* @async
* @function getTransactions
* @param {Object} filter - MongoDB filter object to apply when querying transactions.
* @returns {Promise<Array>} A promise that resolves to an array of matched transactions.
* @throws {Error} Throws an error if querying the database fails.
*/
async function getTransactions(filter) {
try {
return await Transaction.find(filter).lean();
} catch (error) {
logger.error('Error querying transactions:', error);
throw error;
}
}
module.exports = {
getTransactions,
createTransaction,
createAutoRefillTransaction,
createStructuredTransaction,
};

View file

@ -1,156 +0,0 @@
const { logger } = require('@librechat/data-schemas');
const { ViolationTypes } = require('librechat-data-provider');
const { createAutoRefillTransaction } = require('./Transaction');
const { logViolation } = require('~/cache');
const { getMultiplier } = require('./tx');
const { Balance } = require('~/db/models');
function isInvalidDate(date) {
return isNaN(date);
}
/**
* Simple check method that calculates token cost and returns balance info.
* The auto-refill logic has been moved to balanceMethods.js to prevent circular dependencies.
*/
const checkBalanceRecord = async function ({
user,
model,
endpoint,
valueKey,
tokenType,
amount,
endpointTokenConfig,
}) {
const multiplier = getMultiplier({ valueKey, tokenType, model, endpoint, endpointTokenConfig });
const tokenCost = amount * multiplier;
// Retrieve the balance record
let record = await Balance.findOne({ user }).lean();
if (!record) {
logger.debug('[Balance.check] No balance record found for user', { user });
return {
canSpend: false,
balance: 0,
tokenCost,
};
}
let balance = record.tokenCredits;
logger.debug('[Balance.check] Initial state', {
user,
model,
endpoint,
valueKey,
tokenType,
amount,
balance,
multiplier,
endpointTokenConfig: !!endpointTokenConfig,
});
// Only perform auto-refill if spending would bring the balance to 0 or below
if (balance - tokenCost <= 0 && record.autoRefillEnabled && record.refillAmount > 0) {
const lastRefillDate = new Date(record.lastRefill);
const now = new Date();
if (
isInvalidDate(lastRefillDate) ||
now >=
addIntervalToDate(lastRefillDate, record.refillIntervalValue, record.refillIntervalUnit)
) {
try {
/** @type {{ rate: number, user: string, balance: number, transaction: import('@librechat/data-schemas').ITransaction}} */
const result = await createAutoRefillTransaction({
user: user,
tokenType: 'credits',
context: 'autoRefill',
rawAmount: record.refillAmount,
});
balance = result.balance;
} catch (error) {
logger.error('[Balance.check] Failed to record transaction for auto-refill', error);
}
}
}
logger.debug('[Balance.check] Token cost', { tokenCost });
return { canSpend: balance >= tokenCost, balance, tokenCost };
};
/**
* Adds a time interval to a given date.
* @param {Date} date - The starting date.
* @param {number} value - The numeric value of the interval.
* @param {'seconds'|'minutes'|'hours'|'days'|'weeks'|'months'} unit - The unit of time.
* @returns {Date} A new Date representing the starting date plus the interval.
*/
const addIntervalToDate = (date, value, unit) => {
const result = new Date(date);
switch (unit) {
case 'seconds':
result.setSeconds(result.getSeconds() + value);
break;
case 'minutes':
result.setMinutes(result.getMinutes() + value);
break;
case 'hours':
result.setHours(result.getHours() + value);
break;
case 'days':
result.setDate(result.getDate() + value);
break;
case 'weeks':
result.setDate(result.getDate() + value * 7);
break;
case 'months':
result.setMonth(result.getMonth() + value);
break;
default:
break;
}
return result;
};
/**
* Checks the balance for a user and determines if they can spend a certain amount.
* If the user cannot spend the amount, it logs a violation and denies the request.
*
* @async
* @function
* @param {Object} params - The function parameters.
* @param {ServerRequest} params.req - The Express request object.
* @param {Express.Response} params.res - The Express response object.
* @param {Object} params.txData - The transaction data.
* @param {string} params.txData.user - The user ID or identifier.
* @param {('prompt' | 'completion')} params.txData.tokenType - The type of token.
* @param {number} params.txData.amount - The amount of tokens.
* @param {string} params.txData.model - The model name or identifier.
* @param {string} [params.txData.endpointTokenConfig] - The token configuration for the endpoint.
* @returns {Promise<boolean>} Throws error if the user cannot spend the amount.
* @throws {Error} Throws an error if there's an issue with the balance check.
*/
const checkBalance = async ({ req, res, txData }) => {
const { canSpend, balance, tokenCost } = await checkBalanceRecord(txData);
if (canSpend) {
return true;
}
const type = ViolationTypes.TOKEN_BALANCE;
const errorMessage = {
type,
balance,
tokenCost,
promptTokens: txData.amount,
};
if (txData.generations && txData.generations.length > 0) {
errorMessage.generations = txData.generations;
}
await logViolation(req, res, type, errorMessage, 0);
throw new Error(JSON.stringify(errorMessage));
};
module.exports = {
checkBalance,
};

View file

@ -1,48 +1,22 @@
const mongoose = require('mongoose');
const { createMethods } = require('@librechat/data-schemas');
const methods = createMethods(mongoose);
const { comparePassword } = require('./userMethods');
const {
getMessage,
getMessages,
saveMessage,
recordMessage,
updateMessage,
deleteMessagesSince,
deleteMessages,
} = require('./Message');
const { getConvoTitle, getConvo, saveConvo, deleteConvos } = require('./Conversation');
const { getPreset, getPresets, savePreset, deletePresets } = require('./Preset');
const { File } = require('~/db/models');
const { matchModelName, findMatchingPattern } = require('@librechat/api');
const getLogStores = require('~/cache/getLogStores');
const methods = createMethods(mongoose, {
matchModelName,
findMatchingPattern,
getCache: getLogStores,
});
const seedDatabase = async () => {
await methods.initializeRoles();
await methods.seedDefaultRoles();
await methods.ensureDefaultCategories();
await methods.seedSystemGrants();
};
module.exports = {
...methods,
seedDatabase,
comparePassword,
getMessage,
getMessages,
saveMessage,
recordMessage,
updateMessage,
deleteMessagesSince,
deleteMessages,
getConvoTitle,
getConvo,
saveConvo,
deleteConvos,
getPreset,
getPresets,
savePreset,
deletePresets,
Files: File,
};

View file

@ -1,24 +0,0 @@
const { logger } = require('@librechat/data-schemas');
const { updateInterfacePermissions: updateInterfacePerms } = require('@librechat/api');
const { getRoleByName, updateAccessPermissions } = require('./Role');
/**
* Update interface permissions based on app configuration.
* Must be done independently from loading the app config.
* @param {AppConfig} appConfig
*/
async function updateInterfacePermissions(appConfig) {
try {
await updateInterfacePerms({
appConfig,
getRoleByName,
updateAccessPermissions,
});
} catch (error) {
logger.error('Error updating interface permissions:', error);
}
}
module.exports = {
updateInterfacePermissions,
};

View file

@ -1,68 +0,0 @@
const mongoose = require('mongoose');
const { logger, hashToken, getRandomValues } = require('@librechat/data-schemas');
const { createToken, findToken } = require('~/models');
/**
* @module inviteUser
* @description This module provides functions to create and get user invites
*/
/**
* @function createInvite
* @description This function creates a new user invite
* @param {string} email - The email of the user to invite
* @returns {Promise<Object>} A promise that resolves to the saved invite document
* @throws {Error} If there is an error creating the invite
*/
const createInvite = async (email) => {
try {
const token = await getRandomValues(32);
const hash = await hashToken(token);
const encodedToken = encodeURIComponent(token);
const fakeUserId = new mongoose.Types.ObjectId();
await createToken({
userId: fakeUserId,
email,
token: hash,
createdAt: Date.now(),
expiresIn: 604800,
});
return encodedToken;
} catch (error) {
logger.error('[createInvite] Error creating invite', error);
return { message: 'Error creating invite' };
}
};
/**
* @function getInvite
* @description This function retrieves a user invite
* @param {string} encodedToken - The token of the invite to retrieve
* @param {string} email - The email of the user to validate
* @returns {Promise<Object>} A promise that resolves to the retrieved invite document
* @throws {Error} If there is an error retrieving the invite, if the invite does not exist, or if the email does not match
*/
const getInvite = async (encodedToken, email) => {
try {
const token = decodeURIComponent(encodedToken);
const hash = await hashToken(token);
const invite = await findToken({ token: hash, email });
if (!invite) {
throw new Error('Invite not found or email does not match');
}
return invite;
} catch (error) {
logger.error('[getInvite] Error getting invite:', error);
return { error: true, message: error.message };
}
};
module.exports = {
createInvite,
getInvite,
};

View file

@ -1,218 +0,0 @@
const { logger } = require('@librechat/data-schemas');
const { getCustomEndpointConfig } = require('@librechat/api');
const {
Tools,
Constants,
isAgentsEndpoint,
isEphemeralAgentId,
appendAgentIdSuffix,
encodeEphemeralAgentId,
} = require('librechat-data-provider');
const { getMCPServerTools } = require('~/server/services/Config');
const { mcp_all, mcp_delimiter } = Constants;
/**
* Constant for added conversation agent ID
*/
const ADDED_AGENT_ID = 'added_agent';
/**
* Get an agent document based on the provided ID.
* @param {Object} searchParameter - The search parameters to find the agent.
* @param {string} searchParameter.id - The ID of the agent.
* @returns {Promise<import('librechat-data-provider').Agent|null>}
*/
let getAgent;
/**
* Set the getAgent function (dependency injection to avoid circular imports)
* @param {Function} fn
*/
const setGetAgent = (fn) => {
getAgent = fn;
};
/**
* Load an agent from an added conversation (TConversation).
* Used for multi-convo parallel agent execution.
*
* @param {Object} params
* @param {import('express').Request} params.req
* @param {import('librechat-data-provider').TConversation} params.conversation - The added conversation
* @param {import('librechat-data-provider').Agent} [params.primaryAgent] - The primary agent (used to duplicate tools when both are ephemeral)
* @returns {Promise<import('librechat-data-provider').Agent|null>} The agent config as a plain object, or null if invalid.
*/
const loadAddedAgent = async ({ req, conversation, primaryAgent }) => {
if (!conversation) {
return null;
}
if (conversation.agent_id && !isEphemeralAgentId(conversation.agent_id)) {
let agent = req.resolvedAddedAgent;
if (!agent) {
if (!getAgent) {
throw new Error('getAgent not initialized - call setGetAgent first');
}
agent = await getAgent({ id: conversation.agent_id });
}
if (!agent) {
logger.warn(`[loadAddedAgent] Agent ${conversation.agent_id} not found`);
return null;
}
agent.version = agent.versions ? agent.versions.length : 0;
// Append suffix to distinguish from primary agent (matches ephemeral format)
// This is needed when both agents have the same ID or for consistent parallel content attribution
agent.id = appendAgentIdSuffix(agent.id, 1);
return agent;
}
// Otherwise, create an ephemeral agent config from the conversation
const { model, endpoint, promptPrefix, spec, ...rest } = conversation;
if (!endpoint || !model) {
logger.warn('[loadAddedAgent] Missing required endpoint or model for ephemeral agent');
return null;
}
// If both primary and added agents are ephemeral, duplicate tools from primary agent
const primaryIsEphemeral = primaryAgent && isEphemeralAgentId(primaryAgent.id);
if (primaryIsEphemeral && Array.isArray(primaryAgent.tools)) {
// Get endpoint config and model spec for display name fallbacks
const appConfig = req.config;
let endpointConfig = appConfig?.endpoints?.[endpoint];
if (!isAgentsEndpoint(endpoint) && !endpointConfig) {
try {
endpointConfig = getCustomEndpointConfig({ endpoint, appConfig });
} catch (err) {
logger.error('[loadAddedAgent] Error getting custom endpoint config', err);
}
}
// Look up model spec for label fallback
const modelSpecs = appConfig?.modelSpecs?.list;
const modelSpec = spec != null && spec !== '' ? modelSpecs?.find((s) => s.name === spec) : null;
// For ephemeral agents, use modelLabel if provided, then model spec's label,
// then modelDisplayLabel from endpoint config, otherwise empty string to show model name
const sender = rest.modelLabel ?? modelSpec?.label ?? endpointConfig?.modelDisplayLabel ?? '';
const ephemeralId = encodeEphemeralAgentId({ endpoint, model, sender, index: 1 });
return {
id: ephemeralId,
instructions: promptPrefix || '',
provider: endpoint,
model_parameters: {},
model,
tools: [...primaryAgent.tools],
};
}
// Extract ephemeral agent options from conversation if present
const ephemeralAgent = rest.ephemeralAgent;
const mcpServers = new Set(ephemeralAgent?.mcp);
const userId = req.user?.id;
// Check model spec for MCP servers
const modelSpecs = req.config?.modelSpecs?.list;
let modelSpec = null;
if (spec != null && spec !== '') {
modelSpec = modelSpecs?.find((s) => s.name === spec) || null;
}
if (modelSpec?.mcpServers) {
for (const mcpServer of modelSpec.mcpServers) {
mcpServers.add(mcpServer);
}
}
/** @type {string[]} */
const tools = [];
if (ephemeralAgent?.execute_code === true || modelSpec?.executeCode === true) {
tools.push(Tools.execute_code);
}
if (ephemeralAgent?.file_search === true || modelSpec?.fileSearch === true) {
tools.push(Tools.file_search);
}
if (ephemeralAgent?.web_search === true || modelSpec?.webSearch === true) {
tools.push(Tools.web_search);
}
const addedServers = new Set();
if (mcpServers.size > 0) {
for (const mcpServer of mcpServers) {
if (addedServers.has(mcpServer)) {
continue;
}
const serverTools = await getMCPServerTools(userId, mcpServer);
if (!serverTools) {
tools.push(`${mcp_all}${mcp_delimiter}${mcpServer}`);
addedServers.add(mcpServer);
continue;
}
tools.push(...Object.keys(serverTools));
addedServers.add(mcpServer);
}
}
// Build model_parameters from conversation fields
const model_parameters = {};
const paramKeys = [
'temperature',
'top_p',
'topP',
'topK',
'presence_penalty',
'frequency_penalty',
'maxOutputTokens',
'maxTokens',
'max_tokens',
];
for (const key of paramKeys) {
if (rest[key] != null) {
model_parameters[key] = rest[key];
}
}
// Get endpoint config for modelDisplayLabel fallback
const appConfig = req.config;
let endpointConfig = appConfig?.endpoints?.[endpoint];
if (!isAgentsEndpoint(endpoint) && !endpointConfig) {
try {
endpointConfig = getCustomEndpointConfig({ endpoint, appConfig });
} catch (err) {
logger.error('[loadAddedAgent] Error getting custom endpoint config', err);
}
}
// For ephemeral agents, use modelLabel if provided, then model spec's label,
// then modelDisplayLabel from endpoint config, otherwise empty string to show model name
const sender = rest.modelLabel ?? modelSpec?.label ?? endpointConfig?.modelDisplayLabel ?? '';
/** Encoded ephemeral agent ID with endpoint, model, sender, and index=1 to distinguish from primary */
const ephemeralId = encodeEphemeralAgentId({ endpoint, model, sender, index: 1 });
const result = {
id: ephemeralId,
instructions: promptPrefix || '',
provider: endpoint,
model_parameters,
model,
tools,
};
if (ephemeralAgent?.artifacts != null && ephemeralAgent.artifacts) {
result.artifacts = ephemeralAgent.artifacts;
}
return result;
};
module.exports = {
ADDED_AGENT_ID,
loadAddedAgent,
setGetAgent,
};

View file

@ -1,140 +0,0 @@
const { logger } = require('@librechat/data-schemas');
const { createTransaction, createStructuredTransaction } = require('./Transaction');
/**
* Creates up to two transactions to record the spending of tokens.
*
* @function
* @async
* @param {txData} txData - Transaction data.
* @param {Object} tokenUsage - The number of tokens used.
* @param {Number} tokenUsage.promptTokens - The number of prompt tokens used.
* @param {Number} tokenUsage.completionTokens - The number of completion tokens used.
* @returns {Promise<void>} - Returns nothing.
* @throws {Error} - Throws an error if there's an issue creating the transactions.
*/
const spendTokens = async (txData, tokenUsage) => {
const { promptTokens, completionTokens } = tokenUsage;
logger.debug(
`[spendTokens] conversationId: ${txData.conversationId}${
txData?.context ? ` | Context: ${txData?.context}` : ''
} | Token usage: `,
{
promptTokens,
completionTokens,
},
);
let prompt, completion;
const normalizedPromptTokens = Math.max(promptTokens ?? 0, 0);
try {
if (promptTokens !== undefined) {
prompt = await createTransaction({
...txData,
tokenType: 'prompt',
rawAmount: promptTokens === 0 ? 0 : -normalizedPromptTokens,
inputTokenCount: normalizedPromptTokens,
});
}
if (completionTokens !== undefined) {
completion = await createTransaction({
...txData,
tokenType: 'completion',
rawAmount: completionTokens === 0 ? 0 : -Math.max(completionTokens, 0),
inputTokenCount: normalizedPromptTokens,
});
}
if (prompt || completion) {
logger.debug('[spendTokens] Transaction data record against balance:', {
user: txData.user,
prompt: prompt?.prompt,
promptRate: prompt?.rate,
completion: completion?.completion,
completionRate: completion?.rate,
balance: completion?.balance ?? prompt?.balance,
});
} else {
logger.debug('[spendTokens] No transactions incurred against balance');
}
} catch (err) {
logger.error('[spendTokens]', err);
}
};
/**
* Creates transactions to record the spending of structured tokens.
*
* @function
* @async
* @param {txData} txData - Transaction data.
* @param {Object} tokenUsage - The number of tokens used.
* @param {Object} tokenUsage.promptTokens - The number of prompt tokens used.
* @param {Number} tokenUsage.promptTokens.input - The number of input tokens.
* @param {Number} tokenUsage.promptTokens.write - The number of write tokens.
* @param {Number} tokenUsage.promptTokens.read - The number of read tokens.
* @param {Number} tokenUsage.completionTokens - The number of completion tokens used.
* @returns {Promise<void>} - Returns nothing.
* @throws {Error} - Throws an error if there's an issue creating the transactions.
*/
const spendStructuredTokens = async (txData, tokenUsage) => {
const { promptTokens, completionTokens } = tokenUsage;
logger.debug(
`[spendStructuredTokens] conversationId: ${txData.conversationId}${
txData?.context ? ` | Context: ${txData?.context}` : ''
} | Token usage: `,
{
promptTokens,
completionTokens,
},
);
let prompt, completion;
try {
if (promptTokens) {
const input = Math.max(promptTokens.input ?? 0, 0);
const write = Math.max(promptTokens.write ?? 0, 0);
const read = Math.max(promptTokens.read ?? 0, 0);
const totalInputTokens = input + write + read;
prompt = await createStructuredTransaction({
...txData,
tokenType: 'prompt',
inputTokens: -input,
writeTokens: -write,
readTokens: -read,
inputTokenCount: totalInputTokens,
});
}
if (completionTokens) {
const totalInputTokens = promptTokens
? Math.max(promptTokens.input ?? 0, 0) +
Math.max(promptTokens.write ?? 0, 0) +
Math.max(promptTokens.read ?? 0, 0)
: undefined;
completion = await createTransaction({
...txData,
tokenType: 'completion',
rawAmount: -Math.max(completionTokens, 0),
inputTokenCount: totalInputTokens,
});
}
if (prompt || completion) {
logger.debug('[spendStructuredTokens] Transaction data record against balance:', {
user: txData.user,
prompt: prompt?.prompt,
promptRate: prompt?.rate,
completion: completion?.completion,
completionRate: completion?.rate,
balance: completion?.balance ?? prompt?.balance,
});
} else {
logger.debug('[spendStructuredTokens] No transactions incurred against balance');
}
} catch (err) {
logger.error('[spendStructuredTokens]', err);
}
return { prompt, completion };
};
module.exports = { spendTokens, spendStructuredTokens };

View file

@ -1,31 +0,0 @@
const bcrypt = require('bcryptjs');
/**
* Compares the provided password with the user's password.
*
* @param {IUser} user - The user to compare the password for.
* @param {string} candidatePassword - The password to test against the user's password.
* @returns {Promise<boolean>} A promise that resolves to a boolean indicating if the password matches.
*/
const comparePassword = async (user, candidatePassword) => {
if (!user) {
throw new Error('No user provided');
}
if (!user.password) {
throw new Error('No password, likely an email first registered via Social/OIDC login');
}
return new Promise((resolve, reject) => {
bcrypt.compare(candidatePassword, user.password, (err, isMatch) => {
if (err) {
reject(err);
}
resolve(isMatch);
});
});
};
module.exports = {
comparePassword,
};

View file

@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "v0.8.3",
"version": "v0.8.4",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
@ -35,7 +35,7 @@
"homepage": "https://librechat.ai",
"dependencies": {
"@anthropic-ai/vertex-sdk": "^0.14.3",
"@aws-sdk/client-bedrock-runtime": "^3.980.0",
"@aws-sdk/client-bedrock-runtime": "^3.1013.0",
"@aws-sdk/client-s3": "^3.980.0",
"@aws-sdk/s3-request-presigner": "^3.758.0",
"@azure/identity": "^4.7.0",
@ -44,7 +44,7 @@
"@google/genai": "^1.19.0",
"@keyv/redis": "^4.3.3",
"@langchain/core": "^0.3.80",
"@librechat/agents": "^3.1.56",
"@librechat/agents": "^3.1.63",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
@ -52,7 +52,7 @@
"@node-saml/passport-saml": "^5.1.0",
"@smithy/node-http-handler": "^4.4.5",
"ai-tokenizer": "^1.0.6",
"axios": "^1.13.5",
"axios": "1.13.6",
"bcryptjs": "^2.4.3",
"compression": "^1.8.1",
"connect-redis": "^8.1.0",
@ -70,7 +70,7 @@
"file-type": "^21.3.2",
"firebase": "^11.0.2",
"form-data": "^4.0.4",
"handlebars": "^4.7.7",
"handlebars": "^4.7.9",
"https-proxy-agent": "^7.0.6",
"ioredis": "^5.3.2",
"js-yaml": "^4.1.1",
@ -91,7 +91,7 @@
"multer": "^2.1.1",
"nanoid": "^3.3.7",
"node-fetch": "^2.7.0",
"nodemailer": "^7.0.11",
"nodemailer": "^8.0.4",
"ollama": "^0.5.0",
"openai": "5.8.2",
"openid-client": "^6.5.0",
@ -113,6 +113,7 @@
"winston": "^3.11.0",
"winston-daily-rotate-file": "^5.0.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yauzl": "^3.2.1",
"zod": "^3.22.4"
},
"devDependencies": {

View file

@ -123,9 +123,6 @@ function disposeClient(client) {
if (client.maxContextTokens) {
client.maxContextTokens = null;
}
if (client.contextStrategy) {
client.contextStrategy = null;
}
if (client.currentDateString) {
client.currentDateString = null;
}

View file

@ -1,24 +1,22 @@
const { Balance } = require('~/db/models');
const { findBalanceByUser } = require('~/models');
async function balanceController(req, res) {
const balanceData = await Balance.findOne(
{ user: req.user.id },
'-_id tokenCredits autoRefillEnabled refillIntervalValue refillIntervalUnit lastRefill refillAmount',
).lean();
const balanceData = await findBalanceByUser(req.user.id);
if (!balanceData) {
return res.status(404).json({ error: 'Balance not found' });
}
// If auto-refill is not enabled, remove auto-refill related fields from the response
if (!balanceData.autoRefillEnabled) {
delete balanceData.refillIntervalValue;
delete balanceData.refillIntervalUnit;
delete balanceData.lastRefill;
delete balanceData.refillAmount;
const { _id: _, ...result } = balanceData;
if (!result.autoRefillEnabled) {
delete result.refillIntervalValue;
delete result.refillIntervalUnit;
delete result.lastRefill;
delete result.refillAmount;
}
res.status(200).json(balanceData);
res.status(200).json(result);
}
module.exports = balanceController;

View file

@ -1,40 +1,12 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config');
const { getLogStores } = require('~/cache');
/**
* @param {ServerRequest} req
* @returns {Promise<TModelsConfig>} The models config.
*/
const getModelsConfig = async (req) => {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
let modelsConfig = await cache.get(CacheKeys.MODELS_CONFIG);
if (!modelsConfig) {
modelsConfig = await loadModels(req);
}
const getModelsConfig = (req) => loadModels(req);
return modelsConfig;
};
/**
* Loads the models from the config.
* @param {ServerRequest} req - The Express request object.
* @returns {Promise<TModelsConfig>} The models config.
*/
async function loadModels(req) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cachedModelsConfig = await cache.get(CacheKeys.MODELS_CONFIG);
if (cachedModelsConfig) {
return cachedModelsConfig;
}
const defaultModelsConfig = await loadDefaultModels(req);
const customModelsConfig = await loadConfigModels(req);
const modelConfig = { ...defaultModelsConfig, ...customModelsConfig };
await cache.set(CacheKeys.MODELS_CONFIG, modelConfig);
return modelConfig;
return { ...defaultModelsConfig, ...customModelsConfig };
}
async function modelController(req, res) {

View file

@ -9,22 +9,17 @@ const { enrichRemoteAgentPrincipals, backfillRemoteAgentPermissions } = require(
const {
bulkUpdateResourcePermissions,
ensureGroupPrincipalExists,
getResourcePermissionsMap,
findAccessibleResources,
getEffectivePermissions,
ensurePrincipalExists,
getAvailableRoles,
findAccessibleResources,
getResourcePermissionsMap,
} = require('~/server/services/PermissionService');
const {
searchPrincipals: searchLocalPrincipals,
sortPrincipalsByRelevance,
calculateRelevanceScore,
} = require('~/models');
const {
entraIdPrincipalFeatureEnabled,
searchEntraIdPrincipals,
} = require('~/server/services/GraphApiService');
const { AclEntry, AccessRole } = require('~/db/models');
const db = require('~/models');
/**
* Generic controller for resource permission endpoints
@ -155,6 +150,18 @@ const updateResourcePermissions = async (req, res) => {
grantedBy: userId,
});
const isAgentResource =
resourceType === ResourceType.AGENT || resourceType === ResourceType.REMOTE_AGENT;
const revokedUserIds = results.revoked
.filter((p) => p.type === PrincipalType.USER && p.id)
.map((p) => p.id);
if (isAgentResource && revokedUserIds.length > 0) {
db.removeAgentFromUserFavorites(resourceId, revokedUserIds).catch((err) => {
logger.error('[removeRevokedAgentFromFavorites] Error cleaning up favorites', err);
});
}
/** @type {TUpdateResourcePermissionsResponse} */
const response = {
message: 'Permissions updated successfully',
@ -185,8 +192,7 @@ const getResourcePermissions = async (req, res) => {
const { resourceType, resourceId } = req.params;
validateResourceType(resourceType);
// Use aggregation pipeline for efficient single-query data retrieval
const results = await AclEntry.aggregate([
const results = await db.aggregateAclEntries([
// Match ACL entries for this resource
{
$match: {
@ -282,7 +288,12 @@ const getResourcePermissions = async (req, res) => {
}
if (resourceType === ResourceType.REMOTE_AGENT) {
const enricherDeps = { AclEntry, AccessRole, logger };
const enricherDeps = {
aggregateAclEntries: db.aggregateAclEntries,
bulkWriteAclEntries: db.bulkWriteAclEntries,
findRoleByIdentifier: db.findRoleByIdentifier,
logger,
};
const enrichResult = await enrichRemoteAgentPrincipals(enricherDeps, resourceId, principals);
principals = enrichResult.principals;
backfillRemoteAgentPermissions(enricherDeps, resourceId, enrichResult.entriesToBackfill);
@ -399,7 +410,7 @@ const searchPrincipals = async (req, res) => {
typeFilters = validTypes.length > 0 ? validTypes : null;
}
const localResults = await searchLocalPrincipals(query.trim(), searchLimit, typeFilters);
const localResults = await db.searchPrincipals(query.trim(), searchLimit, typeFilters);
let allPrincipals = [...localResults];
const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
@ -455,10 +466,11 @@ const searchPrincipals = async (req, res) => {
}
const scoredResults = allPrincipals.map((item) => ({
...item,
_searchScore: calculateRelevanceScore(item, query.trim()),
_searchScore: db.calculateRelevanceScore(item, query.trim()),
}));
const finalResults = sortPrincipalsByRelevance(scoredResults)
const finalResults = db
.sortPrincipalsByRelevance(scoredResults)
.slice(0, searchLimit)
.map((result) => {
const { _searchScore, ...resultWithoutScore } = result;

View file

@ -1,61 +1,37 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const { getToolkitKey, checkPluginAuth, filterUniquePlugins } = require('@librechat/api');
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
const { availableTools, toolkits } = require('~/app/clients/tools');
const { getAppConfig } = require('~/server/services/Config');
const { getLogStores } = require('~/cache');
const getAvailablePluginsController = async (req, res) => {
try {
const cache = getLogStores(CacheKeys.TOOL_CACHE);
const cachedPlugins = await cache.get(CacheKeys.PLUGINS);
if (cachedPlugins) {
res.status(200).json(cachedPlugins);
return;
}
const appConfig = await getAppConfig({ role: req.user?.role });
/** @type {{ filteredTools: string[], includedTools: string[] }} */
const appConfig = await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId });
const { filteredTools = [], includedTools = [] } = appConfig;
/** @type {import('@librechat/api').LCManifestTool[]} */
const pluginManifest = availableTools;
const uniquePlugins = filterUniquePlugins(pluginManifest);
let authenticatedPlugins = [];
const uniquePlugins = filterUniquePlugins(availableTools);
const includeSet = new Set(includedTools);
const filterSet = new Set(filteredTools);
/** includedTools takes precedence — filteredTools ignored when both are set. */
const plugins = [];
for (const plugin of uniquePlugins) {
authenticatedPlugins.push(
checkPluginAuth(plugin) ? { ...plugin, authenticated: true } : plugin,
);
if (includeSet.size > 0) {
if (!includeSet.has(plugin.pluginKey)) {
continue;
}
} else if (filterSet.has(plugin.pluginKey)) {
continue;
}
plugins.push(checkPluginAuth(plugin) ? { ...plugin, authenticated: true } : plugin);
}
let plugins = authenticatedPlugins;
if (includedTools.length > 0) {
plugins = plugins.filter((plugin) => includedTools.includes(plugin.pluginKey));
} else {
plugins = plugins.filter((plugin) => !filteredTools.includes(plugin.pluginKey));
}
await cache.set(CacheKeys.PLUGINS, plugins);
res.status(200).json(plugins);
} catch (error) {
res.status(500).json({ message: error.message });
}
};
/**
* Retrieves and returns a list of available tools, either from a cache or by reading a plugin manifest file.
*
* This function first attempts to retrieve the list of tools from a cache. If the tools are not found in the cache,
* it reads a plugin manifest file, filters for unique plugins, and determines if each plugin is authenticated.
* Only plugins that are marked as available in the application's local state are included in the final list.
* The resulting list of tools is then cached and sent to the client.
*
* @param {object} req - The request object, containing information about the HTTP request.
* @param {object} res - The response object, used to send back the desired HTTP response.
* @returns {Promise<void>} A promise that resolves when the function has completed.
*/
const getAvailableTools = async (req, res) => {
try {
const userId = req.user?.id;
@ -63,18 +39,10 @@ const getAvailableTools = async (req, res) => {
logger.warn('[getAvailableTools] User ID not found in request');
return res.status(401).json({ message: 'Unauthorized' });
}
const cache = getLogStores(CacheKeys.TOOL_CACHE);
const cachedToolsArray = await cache.get(CacheKeys.TOOLS);
const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role }));
const appConfig =
req.config ?? (await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId }));
// Return early if we have cached tools
if (cachedToolsArray != null) {
res.status(200).json(cachedToolsArray);
return;
}
/** @type {Record<string, FunctionTool> | null} Get tool definitions to filter which tools are actually available */
let toolDefinitions = await getCachedTools();
if (toolDefinitions == null && appConfig?.availableTools != null) {
@ -83,26 +51,17 @@ const getAvailableTools = async (req, res) => {
toolDefinitions = appConfig.availableTools;
}
/** @type {import('@librechat/api').LCManifestTool[]} */
let pluginManifest = availableTools;
const uniquePlugins = filterUniquePlugins(availableTools);
const toolDefKeysList = toolDefinitions ? Object.keys(toolDefinitions) : null;
const toolDefKeys = toolDefKeysList ? new Set(toolDefKeysList) : null;
/** @type {TPlugin[]} Deduplicate and authenticate plugins */
const uniquePlugins = filterUniquePlugins(pluginManifest);
const authenticatedPlugins = uniquePlugins.map((plugin) => {
if (checkPluginAuth(plugin)) {
return { ...plugin, authenticated: true };
} else {
return plugin;
}
});
/** Filter plugins based on availability */
const toolsOutput = [];
for (const plugin of authenticatedPlugins) {
const isToolDefined = toolDefinitions?.[plugin.pluginKey] !== undefined;
for (const plugin of uniquePlugins) {
const isToolDefined = toolDefKeys?.has(plugin.pluginKey) === true;
const isToolkit =
plugin.toolkit === true &&
Object.keys(toolDefinitions ?? {}).some(
toolDefKeysList != null &&
toolDefKeysList.some(
(key) => getToolkitKey({ toolkits, toolName: key }) === plugin.pluginKey,
);
@ -110,13 +69,10 @@ const getAvailableTools = async (req, res) => {
continue;
}
toolsOutput.push(plugin);
toolsOutput.push(checkPluginAuth(plugin) ? { ...plugin, authenticated: true } : plugin);
}
const finalTools = filterUniquePlugins(toolsOutput);
await cache.set(CacheKeys.TOOLS, finalTools);
res.status(200).json(finalTools);
res.status(200).json(toolsOutput);
} catch (error) {
logger.error('[getAvailableTools]', error);
res.status(500).json({ message: error.message });

View file

@ -1,6 +1,4 @@
const { CacheKeys } = require('librechat-data-provider');
const { getCachedTools, getAppConfig } = require('~/server/services/Config');
const { getLogStores } = require('~/cache');
jest.mock('@librechat/data-schemas', () => ({
logger: {
@ -19,22 +17,15 @@ jest.mock('~/server/services/Config', () => ({
setCachedTools: jest.fn(),
}));
// loadAndFormatTools mock removed - no longer used in PluginController
// getMCPManager mock removed - no longer used in PluginController
jest.mock('~/app/clients/tools', () => ({
availableTools: [],
toolkits: [],
}));
jest.mock('~/cache', () => ({
getLogStores: jest.fn(),
}));
const { getAvailableTools, getAvailablePluginsController } = require('./PluginController');
describe('PluginController', () => {
let mockReq, mockRes, mockCache;
let mockReq, mockRes;
beforeEach(() => {
jest.clearAllMocks();
@ -46,17 +37,12 @@ describe('PluginController', () => {
},
};
mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
mockCache = { get: jest.fn(), set: jest.fn() };
getLogStores.mockReturnValue(mockCache);
// Clear availableTools and toolkits arrays before each test
require('~/app/clients/tools').availableTools.length = 0;
require('~/app/clients/tools').toolkits.length = 0;
// Reset getCachedTools mock to ensure clean state
getCachedTools.mockReset();
// Reset getAppConfig mock to ensure clean state with default values
getAppConfig.mockReset();
getAppConfig.mockResolvedValue({
filteredTools: [],
@ -64,31 +50,8 @@ describe('PluginController', () => {
});
});
describe('cache namespace', () => {
it('getAvailablePluginsController should use TOOL_CACHE namespace', async () => {
mockCache.get.mockResolvedValue([]);
await getAvailablePluginsController(mockReq, mockRes);
expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
});
it('getAvailableTools should use TOOL_CACHE namespace', async () => {
mockCache.get.mockResolvedValue([]);
await getAvailableTools(mockReq, mockRes);
expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
});
it('should NOT use CONFIG_STORE namespace for tool/plugin operations', async () => {
mockCache.get.mockResolvedValue([]);
await getAvailablePluginsController(mockReq, mockRes);
await getAvailableTools(mockReq, mockRes);
const allCalls = getLogStores.mock.calls.flat();
expect(allCalls).not.toContain(CacheKeys.CONFIG_STORE);
});
});
describe('getAvailablePluginsController', () => {
it('should use filterUniquePlugins to remove duplicate plugins', async () => {
// Add plugins with duplicates to availableTools
const mockPlugins = [
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
{ name: 'Plugin1', pluginKey: 'key1', description: 'First duplicate' },
@ -97,9 +60,6 @@ describe('PluginController', () => {
require('~/app/clients/tools').availableTools.push(...mockPlugins);
mockCache.get.mockResolvedValue(null);
// Configure getAppConfig to return the expected config
getAppConfig.mockResolvedValueOnce({
filteredTools: [],
includedTools: [],
@ -109,21 +69,16 @@ describe('PluginController', () => {
expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0];
// The real filterUniquePlugins should have removed the duplicate
expect(responseData).toHaveLength(2);
expect(responseData[0].pluginKey).toBe('key1');
expect(responseData[1].pluginKey).toBe('key2');
});
it('should use checkPluginAuth to verify plugin authentication', async () => {
// checkPluginAuth returns false for plugins without authConfig
// so authenticated property won't be added
const mockPlugin = { name: 'Plugin1', pluginKey: 'key1', description: 'First' };
require('~/app/clients/tools').availableTools.push(mockPlugin);
mockCache.get.mockResolvedValue(null);
// Configure getAppConfig to return the expected config
getAppConfig.mockResolvedValueOnce({
filteredTools: [],
includedTools: [],
@ -132,23 +87,9 @@ describe('PluginController', () => {
await getAvailablePluginsController(mockReq, mockRes);
const responseData = mockRes.json.mock.calls[0][0];
// The real checkPluginAuth returns false for plugins without authConfig, so authenticated property is not added
expect(responseData[0].authenticated).toBeUndefined();
});
it('should return cached plugins when available', async () => {
const cachedPlugins = [
{ name: 'CachedPlugin', pluginKey: 'cached', description: 'Cached plugin' },
];
mockCache.get.mockResolvedValue(cachedPlugins);
await getAvailablePluginsController(mockReq, mockRes);
// When cache is hit, we return immediately without processing
expect(mockRes.json).toHaveBeenCalledWith(cachedPlugins);
});
it('should filter plugins based on includedTools', async () => {
const mockPlugins = [
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
@ -156,9 +97,7 @@ describe('PluginController', () => {
];
require('~/app/clients/tools').availableTools.push(...mockPlugins);
mockCache.get.mockResolvedValue(null);
// Configure getAppConfig to return config with includedTools
getAppConfig.mockResolvedValueOnce({
filteredTools: [],
includedTools: ['key1'],
@ -170,6 +109,47 @@ describe('PluginController', () => {
expect(responseData).toHaveLength(1);
expect(responseData[0].pluginKey).toBe('key1');
});
it('should exclude plugins in filteredTools', async () => {
const mockPlugins = [
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
];
require('~/app/clients/tools').availableTools.push(...mockPlugins);
getAppConfig.mockResolvedValueOnce({
filteredTools: ['key2'],
includedTools: [],
});
await getAvailablePluginsController(mockReq, mockRes);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData).toHaveLength(1);
expect(responseData[0].pluginKey).toBe('key1');
});
it('should ignore filteredTools when includedTools is set', async () => {
const mockPlugins = [
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
{ name: 'Plugin3', pluginKey: 'key3', description: 'Third' },
];
require('~/app/clients/tools').availableTools.push(...mockPlugins);
getAppConfig.mockResolvedValueOnce({
includedTools: ['key1', 'key2'],
filteredTools: ['key2'],
});
await getAvailablePluginsController(mockReq, mockRes);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData).toHaveLength(2);
expect(responseData.map((p) => p.pluginKey)).toEqual(['key1', 'key2']);
});
});
describe('getAvailableTools', () => {
@ -185,12 +165,11 @@ describe('PluginController', () => {
},
};
const mockCachedPlugins = [
require('~/app/clients/tools').availableTools.push(
{ name: 'user-tool', pluginKey: 'user-tool', description: 'Duplicate user tool' },
{ name: 'ManifestTool', pluginKey: 'manifest-tool', description: 'Manifest tool' },
];
);
mockCache.get.mockResolvedValue(mockCachedPlugins);
getCachedTools.mockResolvedValueOnce(mockUserTools);
mockReq.config = {
mcpConfig: null,
@ -202,24 +181,19 @@ describe('PluginController', () => {
expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0];
expect(Array.isArray(responseData)).toBe(true);
// The real filterUniquePlugins should have deduplicated tools with same pluginKey
const userToolCount = responseData.filter((tool) => tool.pluginKey === 'user-tool').length;
expect(userToolCount).toBe(1);
});
it('should use checkPluginAuth to verify authentication status', async () => {
// Add a plugin to availableTools that will be checked
const mockPlugin = {
name: 'Tool1',
pluginKey: 'tool1',
description: 'Tool 1',
// No authConfig means checkPluginAuth returns false
};
require('~/app/clients/tools').availableTools.push(mockPlugin);
mockCache.get.mockResolvedValue(null);
// getCachedTools returns the tool definitions
getCachedTools.mockResolvedValueOnce({
tool1: {
type: 'function',
@ -242,7 +216,6 @@ describe('PluginController', () => {
expect(Array.isArray(responseData)).toBe(true);
const tool = responseData.find((t) => t.pluginKey === 'tool1');
expect(tool).toBeDefined();
// The real checkPluginAuth returns false for plugins without authConfig, so authenticated property is not added
expect(tool.authenticated).toBeUndefined();
});
@ -256,15 +229,12 @@ describe('PluginController', () => {
require('~/app/clients/tools').availableTools.push(mockToolkit);
// Mock toolkits to have a mapping
require('~/app/clients/tools').toolkits.push({
name: 'Toolkit1',
pluginKey: 'toolkit1',
tools: ['toolkit1_function'],
});
mockCache.get.mockResolvedValue(null);
// getCachedTools returns the tool definitions
getCachedTools.mockResolvedValueOnce({
toolkit1_function: {
type: 'function',
@ -292,7 +262,7 @@ describe('PluginController', () => {
describe('helper function integration', () => {
it('should handle error cases gracefully', async () => {
mockCache.get.mockRejectedValue(new Error('Cache error'));
getCachedTools.mockRejectedValue(new Error('Cache error'));
await getAvailableTools(mockReq, mockRes);
@ -302,17 +272,7 @@ describe('PluginController', () => {
});
describe('edge cases with undefined/null values', () => {
it('should handle undefined cache gracefully', async () => {
getLogStores.mockReturnValue(undefined);
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(500);
});
it('should handle null cachedTools and cachedUserTools', async () => {
mockCache.get.mockResolvedValue(null);
// getCachedTools returns empty object instead of null
it('should handle null cachedTools', async () => {
getCachedTools.mockResolvedValueOnce({});
mockReq.config = {
mcpConfig: null,
@ -321,51 +281,40 @@ describe('PluginController', () => {
await getAvailableTools(mockReq, mockRes);
// Should handle null values gracefully
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([]);
});
it('should handle when getCachedTools returns undefined', async () => {
mockCache.get.mockResolvedValue(null);
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
// Mock getCachedTools to return undefined
getCachedTools.mockReset();
getCachedTools.mockResolvedValueOnce(undefined);
await getAvailableTools(mockReq, mockRes);
// Should handle undefined values gracefully
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([]);
});
it('should handle empty toolDefinitions object', async () => {
mockCache.get.mockResolvedValue(null);
// Reset getCachedTools to ensure clean state
getCachedTools.mockReset();
getCachedTools.mockResolvedValue({});
mockReq.config = {}; // No mcpConfig at all
mockReq.config = {};
// Ensure no plugins are available
require('~/app/clients/tools').availableTools.length = 0;
await getAvailableTools(mockReq, mockRes);
// With empty tool definitions, no tools should be in the final output
expect(mockRes.json).toHaveBeenCalledWith([]);
});
it('should handle undefined filteredTools and includedTools', async () => {
mockReq.config = {};
mockCache.get.mockResolvedValue(null);
// Configure getAppConfig to return config with undefined properties
// The controller will use default values [] for filteredTools and includedTools
getAppConfig.mockResolvedValueOnce({});
await getAvailablePluginsController(mockReq, mockRes);
@ -382,13 +331,8 @@ describe('PluginController', () => {
toolkit: true,
};
// No need to mock app.locals anymore as it's not used
// Add the toolkit to availableTools
require('~/app/clients/tools').availableTools.push(mockToolkit);
mockCache.get.mockResolvedValue(null);
// getCachedTools returns empty object to avoid null reference error
getCachedTools.mockResolvedValueOnce({});
mockReq.config = {
mcpConfig: null,
@ -397,43 +341,32 @@ describe('PluginController', () => {
await getAvailableTools(mockReq, mockRes);
// Should handle null toolDefinitions gracefully
expect(mockRes.status).toHaveBeenCalledWith(200);
});
it('should handle undefined toolDefinitions when checking isToolDefined (traversaal_search bug)', async () => {
// This test reproduces the bug where toolDefinitions is undefined
// and accessing toolDefinitions[plugin.pluginKey] causes a TypeError
it('should handle undefined toolDefinitions when checking isToolDefined', async () => {
const mockPlugin = {
name: 'Traversaal Search',
pluginKey: 'traversaal_search',
description: 'Search plugin',
};
// Add the plugin to availableTools
require('~/app/clients/tools').availableTools.push(mockPlugin);
mockCache.get.mockResolvedValue(null);
mockReq.config = {
mcpConfig: null,
paths: { structuredTools: '/mock/path' },
};
// CRITICAL: getCachedTools returns undefined
// This is what causes the bug when trying to access toolDefinitions[plugin.pluginKey]
getCachedTools.mockResolvedValueOnce(undefined);
// This should not throw an error with the optional chaining fix
await getAvailableTools(mockReq, mockRes);
// Should handle undefined toolDefinitions gracefully and return empty array
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([]);
});
it('should re-initialize tools from appConfig when cache returns null', async () => {
// Setup: Initial state with tools in appConfig
const mockAppTools = {
tool1: {
type: 'function',
@ -453,15 +386,12 @@ describe('PluginController', () => {
},
};
// Add matching plugins to availableTools
require('~/app/clients/tools').availableTools.push(
{ name: 'Tool 1', pluginKey: 'tool1', description: 'Tool 1' },
{ name: 'Tool 2', pluginKey: 'tool2', description: 'Tool 2' },
);
// Simulate cache cleared state (returns null)
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValueOnce(null); // Global tools (cache cleared)
getCachedTools.mockResolvedValueOnce(null);
mockReq.config = {
filteredTools: [],
@ -469,15 +399,12 @@ describe('PluginController', () => {
availableTools: mockAppTools,
};
// Mock setCachedTools to verify it's called to re-initialize
const { setCachedTools } = require('~/server/services/Config');
await getAvailableTools(mockReq, mockRes);
// Should have re-initialized the cache with tools from appConfig
expect(setCachedTools).toHaveBeenCalledWith(mockAppTools);
// Should still return tools successfully
expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData).toHaveLength(2);
@ -486,29 +413,22 @@ describe('PluginController', () => {
});
it('should handle cache clear without appConfig.availableTools gracefully', async () => {
// Setup: appConfig without availableTools
getAppConfig.mockResolvedValue({
filteredTools: [],
includedTools: [],
// No availableTools property
});
// Clear availableTools array
require('~/app/clients/tools').availableTools.length = 0;
// Cache returns null (cleared state)
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValueOnce(null); // Global tools (cache cleared)
getCachedTools.mockResolvedValueOnce(null);
mockReq.config = {
filteredTools: [],
includedTools: [],
// No availableTools
};
await getAvailableTools(mockReq, mockRes);
// Should handle gracefully without crashing
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([]);
});

View file

@ -1,54 +1,32 @@
const mongoose = require('mongoose');
const { logger, webSearchKeys } = require('@librechat/data-schemas');
const { Tools, CacheKeys, Constants, FileSources } = require('librechat-data-provider');
const {
getNewS3URL,
needsRefresh,
MCPOAuthHandler,
MCPTokenStorage,
normalizeHttpError,
extractWebSearchEnvVars,
} = require('@librechat/api');
const {
deleteAllUserSessions,
deleteAllSharedLinks,
updateUserPlugins,
deleteUserById,
deleteMessages,
deletePresets,
deleteUserKey,
getUserById,
deleteConvos,
deleteFiles,
updateUser,
findToken,
getFiles,
} = require('~/models');
const {
ConversationTag,
AgentApiKey,
Transaction,
MemoryEntry,
Assistant,
AclEntry,
Balance,
Action,
Group,
Token,
User,
} = require('~/db/models');
Tools,
CacheKeys,
Constants,
FileSources,
ResourceType,
} = require('librechat-data-provider');
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
const { verifyOTPOrBackupCode } = require('~/server/services/twoFactorService');
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
const { getMCPManager, getFlowStateManager, getMCPServersRegistry } = require('~/config');
const { invalidateCachedTools } = require('~/server/services/Config/getCachedTools');
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
const { processDeleteRequest } = require('~/server/services/Files/process');
const { getAppConfig } = require('~/server/services/Config');
const { deleteToolCalls } = require('~/models/ToolCall');
const { deleteUserPrompts } = require('~/models/Prompt');
const { deleteUserAgents } = require('~/models/Agent');
const { getLogStores } = require('~/cache');
const db = require('~/models');
const getUserController = async (req, res) => {
const appConfig = await getAppConfig({ role: req.user?.role });
const appConfig = await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId });
/** @type {IUser} */
const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user };
/**
@ -66,7 +44,7 @@ const getUserController = async (req, res) => {
const originalAvatar = userData.avatar;
try {
userData.avatar = await getNewS3URL(userData.avatar);
await updateUser(userData.id, { avatar: userData.avatar });
await db.updateUser(userData.id, { avatar: userData.avatar });
} catch (error) {
userData.avatar = originalAvatar;
logger.error('Error getting new S3 URL for avatar:', error);
@ -77,7 +55,7 @@ const getUserController = async (req, res) => {
const getTermsStatusController = async (req, res) => {
try {
const user = await User.findById(req.user.id);
const user = await db.getUserById(req.user.id, 'termsAccepted');
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
@ -90,7 +68,7 @@ const getTermsStatusController = async (req, res) => {
const acceptTermsController = async (req, res) => {
try {
const user = await User.findByIdAndUpdate(req.user.id, { termsAccepted: true }, { new: true });
const user = await db.updateUser(req.user.id, { termsAccepted: true });
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
@ -103,7 +81,7 @@ const acceptTermsController = async (req, res) => {
const deleteUserFiles = async (req) => {
try {
const userFiles = await getFiles({ user: req.user.id });
const userFiles = await db.getFiles({ user: req.user.id });
await processDeleteRequest({
req,
files: userFiles,
@ -113,13 +91,86 @@ const deleteUserFiles = async (req) => {
}
};
/**
* Deletes MCP servers solely owned by the user and cleans up their ACLs.
* Disconnects live sessions for deleted servers before removing DB records.
* Servers with other owners are left intact; the caller is responsible for
* removing the user's own ACL principal entries separately.
*
* Also handles legacy (pre-ACL) MCP servers that only have the author field set,
* ensuring they are not orphaned if no permission migration has been run.
* @param {string} userId - The ID of the user.
*/
const deleteUserMcpServers = async (userId) => {
try {
const MCPServer = mongoose.models.MCPServer;
const AclEntry = mongoose.models.AclEntry;
if (!MCPServer) {
return;
}
const userObjectId = new mongoose.Types.ObjectId(userId);
const soleOwnedIds = await db.getSoleOwnedResourceIds(userObjectId, ResourceType.MCPSERVER);
const authoredServers = await MCPServer.find({ author: userObjectId })
.select('_id serverName')
.lean();
const migratedEntries =
authoredServers.length > 0
? await AclEntry.find({
resourceType: ResourceType.MCPSERVER,
resourceId: { $in: authoredServers.map((s) => s._id) },
})
.select('resourceId')
.lean()
: [];
const migratedIds = new Set(migratedEntries.map((e) => e.resourceId.toString()));
const legacyServers = authoredServers.filter((s) => !migratedIds.has(s._id.toString()));
const legacyServerIds = legacyServers.map((s) => s._id);
const allServerIdsToDelete = [...soleOwnedIds, ...legacyServerIds];
if (allServerIdsToDelete.length === 0) {
return;
}
const aclOwnedServers =
soleOwnedIds.length > 0
? await MCPServer.find({ _id: { $in: soleOwnedIds } })
.select('serverName')
.lean()
: [];
const allServersToDelete = [...aclOwnedServers, ...legacyServers];
const mcpManager = getMCPManager();
if (mcpManager) {
await Promise.all(
allServersToDelete.map(async (s) => {
await mcpManager.disconnectUserConnection(userId, s.serverName);
await invalidateCachedTools({ userId, serverName: s.serverName });
}),
);
}
await AclEntry.deleteMany({
resourceType: ResourceType.MCPSERVER,
resourceId: { $in: allServerIdsToDelete },
});
await MCPServer.deleteMany({ _id: { $in: allServerIdsToDelete } });
} catch (error) {
logger.error('[deleteUserMcpServers] General error:', error);
}
};
const updateUserPluginsController = async (req, res) => {
const appConfig = await getAppConfig({ role: req.user?.role });
const appConfig = await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId });
const { user } = req;
const { pluginKey, action, auth, isEntityTool } = req.body;
try {
if (!isEntityTool) {
await updateUserPlugins(user._id, user.plugins, pluginKey, action);
await db.updateUserPlugins(user._id, user.plugins, pluginKey, action);
}
if (auth == null) {
@ -243,7 +294,7 @@ const deleteUserController = async (req, res) => {
const { user } = req;
try {
const existingUser = await getUserById(
const existingUser = await db.getUserById(
user.id,
'+totpSecret +backupCodes _id twoFactorEnabled',
);
@ -259,37 +310,34 @@ const deleteUserController = async (req, res) => {
}
}
await deleteMessages({ user: user.id }); // delete user messages
await deleteAllUserSessions({ userId: user.id }); // delete user sessions
await Transaction.deleteMany({ user: user.id }); // delete user transactions
await deleteUserKey({ userId: user.id, all: true }); // delete user keys
await Balance.deleteMany({ user: user._id }); // delete user balances
await deletePresets(user.id); // delete user presets
await db.deleteMessages({ user: user.id });
await db.deleteAllUserSessions({ userId: user.id });
await db.deleteTransactions({ user: user.id });
await db.deleteUserKey({ userId: user.id, all: true });
await db.deleteBalances({ user: user._id });
await db.deletePresets(user.id);
try {
await deleteConvos(user.id); // delete user convos
await db.deleteConvos(user.id);
} catch (error) {
logger.error('[deleteUserController] Error deleting user convos, likely no convos', error);
}
await deleteUserPluginAuth(user.id, null, true); // delete user plugin auth
await deleteUserById(user.id); // delete user
await deleteAllSharedLinks(user.id); // delete user shared links
await deleteUserFiles(req); // delete user files
await deleteFiles(null, user.id); // delete database files in case of orphaned files from previous steps
await deleteToolCalls(user.id); // delete user tool calls
await deleteUserAgents(user.id); // delete user agents
await AgentApiKey.deleteMany({ user: user._id }); // delete user agent API keys
await Assistant.deleteMany({ user: user.id }); // delete user assistants
await ConversationTag.deleteMany({ user: user.id }); // delete user conversation tags
await MemoryEntry.deleteMany({ userId: user.id }); // delete user memory entries
await deleteUserPrompts(req, user.id); // delete user prompts
await Action.deleteMany({ user: user.id }); // delete user actions
await Token.deleteMany({ userId: user.id }); // delete user OAuth tokens
await Group.updateMany(
// remove user from all groups
{ memberIds: user.id },
{ $pull: { memberIds: user.id } },
);
await AclEntry.deleteMany({ principalId: user._id }); // delete user ACL entries
await deleteUserPluginAuth(user.id, null, true);
await db.deleteUserById(user.id);
await db.deleteAllSharedLinks(user.id);
await deleteUserFiles(req);
await db.deleteFiles(null, user.id);
await db.deleteToolCalls(user.id);
await db.deleteUserAgents(user.id);
await db.deleteAllAgentApiKeys(user._id);
await db.deleteAssistants({ user: user.id });
await db.deleteConversationTags({ user: user.id });
await db.deleteAllUserMemories(user.id);
await db.deleteUserPrompts(user.id);
await deleteUserMcpServers(user.id);
await db.deleteActions({ user: user.id });
await db.deleteTokens({ userId: user.id });
await db.removeUserFromAllGroups(user.id);
await db.deleteAclEntries({ principalId: user._id });
logger.info(`User deleted account. Email: ${user.email} ID: ${user.id}`);
res.status(200).send({ message: 'User deleted' });
} catch (err) {
@ -349,7 +397,7 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
const clientTokenData = await MCPTokenStorage.getClientInfoAndMetadata({
userId,
serverName,
findToken,
findToken: db.findToken,
});
if (clientTokenData == null) {
return;
@ -360,7 +408,7 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
const tokens = await MCPTokenStorage.getTokens({
userId,
serverName,
findToken,
findToken: db.findToken,
});
// 3. revoke OAuth tokens at the provider
@ -419,7 +467,7 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
userId,
serverName,
deleteToken: async (filter) => {
await Token.deleteOne(filter);
await db.deleteTokens(filter);
},
});
@ -439,4 +487,5 @@ module.exports = {
verifyEmailController,
updateUserPluginsController,
resendVerificationController,
deleteUserMcpServers,
};

View file

@ -0,0 +1,225 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
jest.mock('@librechat/data-schemas', () => {
const actual = jest.requireActual('@librechat/data-schemas');
return {
...actual,
logger: {
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
},
};
});
jest.mock('~/models', () => {
const _mongoose = require('mongoose');
return {
deleteAllUserSessions: jest.fn().mockResolvedValue(undefined),
deleteAllSharedLinks: jest.fn().mockResolvedValue(undefined),
deleteAllAgentApiKeys: jest.fn().mockResolvedValue(undefined),
deleteConversationTags: jest.fn().mockResolvedValue(undefined),
deleteAllUserMemories: jest.fn().mockResolvedValue(undefined),
deleteTransactions: jest.fn().mockResolvedValue(undefined),
deleteAclEntries: jest.fn().mockResolvedValue(undefined),
updateUserPlugins: jest.fn(),
deleteAssistants: jest.fn().mockResolvedValue(undefined),
deleteUserById: jest.fn().mockResolvedValue(undefined),
deleteUserPrompts: jest.fn().mockResolvedValue(undefined),
deleteMessages: jest.fn().mockResolvedValue(undefined),
deleteBalances: jest.fn().mockResolvedValue(undefined),
deleteActions: jest.fn().mockResolvedValue(undefined),
deletePresets: jest.fn().mockResolvedValue(undefined),
deleteUserKey: jest.fn().mockResolvedValue(undefined),
deleteToolCalls: jest.fn().mockResolvedValue(undefined),
deleteUserAgents: jest.fn().mockResolvedValue(undefined),
deleteTokens: jest.fn().mockResolvedValue(undefined),
deleteConvos: jest.fn().mockResolvedValue(undefined),
deleteFiles: jest.fn().mockResolvedValue(undefined),
updateUser: jest.fn(),
getUserById: jest.fn().mockResolvedValue(null),
findToken: jest.fn(),
getFiles: jest.fn().mockResolvedValue([]),
removeUserFromAllGroups: jest.fn().mockImplementation(async (userId) => {
const Group = _mongoose.models.Group;
await Group.updateMany({ memberIds: userId }, { $pullAll: { memberIds: [userId] } });
}),
};
});
jest.mock('~/server/services/PluginService', () => ({
updateUserPluginAuth: jest.fn(),
deleteUserPluginAuth: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('~/server/services/AuthService', () => ({
verifyEmail: jest.fn(),
resendVerificationEmail: jest.fn(),
}));
jest.mock('sharp', () =>
jest.fn(() => ({
metadata: jest.fn().mockResolvedValue({}),
toFormat: jest.fn().mockReturnThis(),
toBuffer: jest.fn().mockResolvedValue(Buffer.alloc(0)),
})),
);
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
needsRefresh: jest.fn(),
getNewS3URL: jest.fn(),
}));
jest.mock('~/server/services/Files/process', () => ({
processDeleteRequest: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn().mockResolvedValue({}),
getMCPManager: jest.fn(),
getFlowStateManager: jest.fn(),
getMCPServersRegistry: jest.fn(),
}));
jest.mock('~/cache', () => ({
getLogStores: jest.fn(),
}));
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
afterEach(async () => {
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
});
const { deleteUserController } = require('./UserController');
const { Group } = require('~/db/models');
const { deleteConvos } = require('~/models');
describe('deleteUserController', () => {
const mockRes = {
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should return 200 on successful deletion', async () => {
const userId = new mongoose.Types.ObjectId();
const req = { user: { id: userId.toString(), _id: userId, email: 'test@test.com' } };
await deleteUserController(req, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.send).toHaveBeenCalledWith({ message: 'User deleted' });
});
it('should remove the user from all groups via $pullAll', async () => {
const userId = new mongoose.Types.ObjectId();
const userIdStr = userId.toString();
const otherUser = new mongoose.Types.ObjectId().toString();
await Group.create([
{ name: 'Group A', memberIds: [userIdStr, otherUser], source: 'local' },
{ name: 'Group B', memberIds: [userIdStr], source: 'local' },
{ name: 'Group C', memberIds: [otherUser], source: 'local' },
]);
const req = { user: { id: userIdStr, _id: userId, email: 'del@test.com' } };
await deleteUserController(req, mockRes);
const groups = await Group.find({}).sort({ name: 1 }).lean();
expect(groups[0].memberIds).toEqual([otherUser]);
expect(groups[1].memberIds).toEqual([]);
expect(groups[2].memberIds).toEqual([otherUser]);
});
it('should handle user that exists in no groups', async () => {
const userId = new mongoose.Types.ObjectId();
await Group.create({ name: 'Empty', memberIds: ['someone-else'], source: 'local' });
const req = { user: { id: userId.toString(), _id: userId, email: 'no-groups@test.com' } };
await deleteUserController(req, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
const group = await Group.findOne({ name: 'Empty' }).lean();
expect(group.memberIds).toEqual(['someone-else']);
});
it('should remove duplicate memberIds if the user appears more than once', async () => {
const userId = new mongoose.Types.ObjectId();
const userIdStr = userId.toString();
await Group.create({
name: 'Dupes',
memberIds: [userIdStr, 'other', userIdStr],
source: 'local',
});
const req = { user: { id: userIdStr, _id: userId, email: 'dupe@test.com' } };
await deleteUserController(req, mockRes);
const group = await Group.findOne({ name: 'Dupes' }).lean();
expect(group.memberIds).toEqual(['other']);
});
it('should still succeed when deleteConvos throws', async () => {
const userId = new mongoose.Types.ObjectId();
deleteConvos.mockRejectedValueOnce(new Error('no convos'));
const req = { user: { id: userId.toString(), _id: userId, email: 'convos@test.com' } };
await deleteUserController(req, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.send).toHaveBeenCalledWith({ message: 'User deleted' });
});
it('should return 500 when a critical operation fails', async () => {
const userId = new mongoose.Types.ObjectId();
const { deleteMessages } = require('~/models');
deleteMessages.mockRejectedValueOnce(new Error('db down'));
const req = { user: { id: userId.toString(), _id: userId, email: 'fail@test.com' } };
await deleteUserController(req, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Something went wrong.' });
});
it('should use string user.id (not ObjectId user._id) for memberIds removal', async () => {
const userId = new mongoose.Types.ObjectId();
const userIdStr = userId.toString();
const otherUser = 'other-user-id';
await Group.create({
name: 'StringCheck',
memberIds: [userIdStr, otherUser],
source: 'local',
});
const req = { user: { id: userIdStr, _id: userId, email: 'stringcheck@test.com' } };
await deleteUserController(req, mockRes);
const group = await Group.findOne({ name: 'StringCheck' }).lean();
expect(group.memberIds).toEqual([otherUser]);
expect(group.memberIds).not.toContain(userIdStr);
});
});

View file

@ -0,0 +1,242 @@
const mongoose = require('mongoose');
const mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() };
jest.mock('@librechat/data-schemas', () => ({
logger: mockLogger,
}));
const { ResourceType, PrincipalType } = jest.requireActual('librechat-data-provider');
jest.mock('librechat-data-provider', () => ({
...jest.requireActual('librechat-data-provider'),
}));
jest.mock('@librechat/api', () => ({
enrichRemoteAgentPrincipals: jest.fn(),
backfillRemoteAgentPermissions: jest.fn(),
}));
const mockBulkUpdateResourcePermissions = jest.fn();
jest.mock('~/server/services/PermissionService', () => ({
bulkUpdateResourcePermissions: (...args) => mockBulkUpdateResourcePermissions(...args),
ensureGroupPrincipalExists: jest.fn(),
getEffectivePermissions: jest.fn(),
ensurePrincipalExists: jest.fn(),
getAvailableRoles: jest.fn(),
findAccessibleResources: jest.fn(),
getResourcePermissionsMap: jest.fn(),
}));
const mockRemoveAgentFromUserFavorites = jest.fn();
jest.mock('~/models', () => ({
searchPrincipals: jest.fn(),
sortPrincipalsByRelevance: jest.fn(),
calculateRelevanceScore: jest.fn(),
removeAgentFromUserFavorites: (...args) => mockRemoveAgentFromUserFavorites(...args),
}));
jest.mock('~/server/services/GraphApiService', () => ({
entraIdPrincipalFeatureEnabled: jest.fn(() => false),
searchEntraIdPrincipals: jest.fn(),
}));
const { updateResourcePermissions } = require('../PermissionsController');
const createMockReq = (overrides = {}) => ({
params: { resourceType: ResourceType.AGENT, resourceId: '507f1f77bcf86cd799439011' },
body: { updated: [], removed: [], public: false },
user: { id: 'user-1', role: 'USER' },
headers: { authorization: '' },
...overrides,
});
const createMockRes = () => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};
const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
describe('PermissionsController', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('updateResourcePermissions — favorites cleanup', () => {
const agentObjectId = new mongoose.Types.ObjectId().toString();
const revokedUserId = new mongoose.Types.ObjectId().toString();
beforeEach(() => {
mockBulkUpdateResourcePermissions.mockResolvedValue({
granted: [],
updated: [],
revoked: [{ type: PrincipalType.USER, id: revokedUserId, name: 'Revoked User' }],
errors: [],
});
mockRemoveAgentFromUserFavorites.mockResolvedValue(undefined);
});
it('removes agent from revoked users favorites on AGENT resource type', async () => {
const req = createMockReq({
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
body: {
updated: [],
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
public: false,
},
});
const res = createMockRes();
await updateResourcePermissions(req, res);
await flushPromises();
expect(res.status).toHaveBeenCalledWith(200);
expect(mockRemoveAgentFromUserFavorites).toHaveBeenCalledWith(agentObjectId, [revokedUserId]);
});
it('removes agent from revoked users favorites on REMOTE_AGENT resource type', async () => {
const req = createMockReq({
params: { resourceType: ResourceType.REMOTE_AGENT, resourceId: agentObjectId },
body: {
updated: [],
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
public: false,
},
});
const res = createMockRes();
await updateResourcePermissions(req, res);
await flushPromises();
expect(mockRemoveAgentFromUserFavorites).toHaveBeenCalledWith(agentObjectId, [revokedUserId]);
});
it('uses results.revoked (validated) not raw request payload', async () => {
const validId = new mongoose.Types.ObjectId().toString();
const invalidId = 'not-a-valid-id';
mockBulkUpdateResourcePermissions.mockResolvedValue({
granted: [],
updated: [],
revoked: [{ type: PrincipalType.USER, id: validId }],
errors: [{ principal: { type: PrincipalType.USER, id: invalidId }, error: 'Invalid ID' }],
});
const req = createMockReq({
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
body: {
updated: [],
removed: [
{ type: PrincipalType.USER, id: validId },
{ type: PrincipalType.USER, id: invalidId },
],
public: false,
},
});
const res = createMockRes();
await updateResourcePermissions(req, res);
await flushPromises();
expect(mockRemoveAgentFromUserFavorites).toHaveBeenCalledWith(agentObjectId, [validId]);
});
it('skips cleanup when no USER principals are revoked', async () => {
mockBulkUpdateResourcePermissions.mockResolvedValue({
granted: [],
updated: [],
revoked: [{ type: PrincipalType.GROUP, id: 'group-1' }],
errors: [],
});
const req = createMockReq({
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
body: {
updated: [],
removed: [{ type: PrincipalType.GROUP, id: 'group-1' }],
public: false,
},
});
const res = createMockRes();
await updateResourcePermissions(req, res);
await flushPromises();
expect(mockRemoveAgentFromUserFavorites).not.toHaveBeenCalled();
});
it('skips cleanup for non-agent resource types', async () => {
mockBulkUpdateResourcePermissions.mockResolvedValue({
granted: [],
updated: [],
revoked: [{ type: PrincipalType.USER, id: revokedUserId }],
errors: [],
});
const req = createMockReq({
params: { resourceType: ResourceType.PROMPTGROUP, resourceId: agentObjectId },
body: {
updated: [],
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
public: false,
},
});
const res = createMockRes();
await updateResourcePermissions(req, res);
await flushPromises();
expect(res.status).toHaveBeenCalledWith(200);
expect(mockRemoveAgentFromUserFavorites).not.toHaveBeenCalled();
});
it('handles agent not found gracefully', async () => {
mockRemoveAgentFromUserFavorites.mockResolvedValue(undefined);
const req = createMockReq({
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
body: {
updated: [],
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
public: false,
},
});
const res = createMockRes();
await updateResourcePermissions(req, res);
await flushPromises();
expect(mockRemoveAgentFromUserFavorites).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
});
it('logs error when removeAgentFromUserFavorites fails without blocking response', async () => {
mockRemoveAgentFromUserFavorites.mockRejectedValue(new Error('DB connection lost'));
const req = createMockReq({
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
body: {
updated: [],
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
public: false,
},
});
const res = createMockRes();
await updateResourcePermissions(req, res);
await flushPromises();
expect(res.status).toHaveBeenCalledWith(200);
expect(mockLogger.error).toHaveBeenCalledWith(
'[removeRevokedAgentFromFavorites] Error cleaning up favorites',
expect.any(Error),
);
});
});
});

View file

@ -35,6 +35,8 @@ jest.mock('@librechat/api', () => ({
MCPTokenStorage: {},
normalizeHttpError: jest.fn(),
extractWebSearchEnvVars: jest.fn(),
needsRefresh: jest.fn(),
getNewS3URL: jest.fn(),
}));
jest.mock('~/models', () => ({
@ -51,20 +53,20 @@ jest.mock('~/models', () => ({
updateUser: (...args) => mockUpdateUser(...args),
findToken: (...args) => mockFindToken(...args),
getFiles: (...args) => mockGetFiles(...args),
}));
jest.mock('~/db/models', () => ({
ConversationTag: { deleteMany: jest.fn() },
AgentApiKey: { deleteMany: jest.fn() },
Transaction: { deleteMany: jest.fn() },
MemoryEntry: { deleteMany: jest.fn() },
Assistant: { deleteMany: jest.fn() },
AclEntry: { deleteMany: jest.fn() },
Balance: { deleteMany: jest.fn() },
Action: { deleteMany: jest.fn() },
Group: { updateMany: jest.fn() },
Token: { deleteMany: jest.fn() },
User: {},
deleteToolCalls: (...args) => mockDeleteToolCalls(...args),
deleteUserAgents: (...args) => mockDeleteUserAgents(...args),
deleteUserPrompts: (...args) => mockDeleteUserPrompts(...args),
deleteTransactions: jest.fn(),
deleteBalances: jest.fn(),
deleteAllAgentApiKeys: jest.fn(),
deleteAssistants: jest.fn(),
deleteConversationTags: jest.fn(),
deleteAllUserMemories: jest.fn(),
deleteActions: jest.fn(),
deleteTokens: jest.fn(),
removeUserFromAllGroups: jest.fn(),
deleteAclEntries: jest.fn(),
getSoleOwnedResourceIds: jest.fn().mockResolvedValue([]),
}));
jest.mock('~/server/services/PluginService', () => ({
@ -91,11 +93,6 @@ jest.mock('~/server/services/Config/getCachedTools', () => ({
invalidateCachedTools: jest.fn(),
}));
jest.mock('~/server/services/Files/S3/crud', () => ({
needsRefresh: jest.fn(),
getNewS3URL: jest.fn(),
}));
jest.mock('~/server/services/Files/process', () => ({
processDeleteRequest: (...args) => mockProcessDeleteRequest(...args),
}));
@ -104,18 +101,6 @@ jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn(),
}));
jest.mock('~/models/ToolCall', () => ({
deleteToolCalls: (...args) => mockDeleteToolCalls(...args),
}));
jest.mock('~/models/Prompt', () => ({
deleteUserPrompts: (...args) => mockDeleteUserPrompts(...args),
}));
jest.mock('~/models/Agent', () => ({
deleteUserAgents: (...args) => mockDeleteUserAgents(...args),
}));
jest.mock('~/cache', () => ({
getLogStores: jest.fn(),
}));

View file

@ -0,0 +1,319 @@
const mockGetMCPManager = jest.fn();
const mockInvalidateCachedTools = jest.fn();
jest.mock('~/config', () => ({
getMCPManager: (...args) => mockGetMCPManager(...args),
getFlowStateManager: jest.fn(),
getMCPServersRegistry: jest.fn(),
}));
jest.mock('~/server/services/Config/getCachedTools', () => ({
invalidateCachedTools: (...args) => mockInvalidateCachedTools(...args),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn(),
getMCPServerTools: jest.fn(),
}));
const mongoose = require('mongoose');
const { mcpServerSchema } = require('@librechat/data-schemas');
const { MongoMemoryServer } = require('mongodb-memory-server');
const {
ResourceType,
AccessRoleIds,
PrincipalType,
PermissionBits,
} = require('librechat-data-provider');
const permissionService = require('~/server/services/PermissionService');
const { deleteUserMcpServers } = require('~/server/controllers/UserController');
const { AclEntry, AccessRole } = require('~/db/models');
let MCPServer;
describe('deleteUserMcpServers', () => {
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
MCPServer = mongoose.models.MCPServer || mongoose.model('MCPServer', mcpServerSchema);
await mongoose.connect(mongoUri);
await AccessRole.create({
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
name: 'MCP Server Owner',
resourceType: ResourceType.MCPSERVER,
permBits:
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
});
await AccessRole.create({
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
name: 'MCP Server Viewer',
resourceType: ResourceType.MCPSERVER,
permBits: PermissionBits.VIEW,
});
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await MCPServer.deleteMany({});
await AclEntry.deleteMany({});
jest.clearAllMocks();
});
test('should delete solely-owned MCP servers and their ACL entries', async () => {
const userId = new mongoose.Types.ObjectId();
const server = await MCPServer.create({
serverName: 'sole-owned-server',
config: { title: 'Test Server' },
author: userId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: server._id,
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
grantedBy: userId,
});
mockGetMCPManager.mockReturnValue({
disconnectUserConnection: jest.fn().mockResolvedValue(undefined),
});
await deleteUserMcpServers(userId.toString());
expect(await MCPServer.findById(server._id)).toBeNull();
const aclEntries = await AclEntry.find({
resourceType: ResourceType.MCPSERVER,
resourceId: server._id,
});
expect(aclEntries).toHaveLength(0);
});
test('should disconnect MCP sessions and invalidate tool cache before deletion', async () => {
const userId = new mongoose.Types.ObjectId();
const mockDisconnect = jest.fn().mockResolvedValue(undefined);
const server = await MCPServer.create({
serverName: 'session-server',
config: { title: 'Session Server' },
author: userId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: server._id,
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
grantedBy: userId,
});
mockGetMCPManager.mockReturnValue({ disconnectUserConnection: mockDisconnect });
await deleteUserMcpServers(userId.toString());
expect(mockDisconnect).toHaveBeenCalledWith(userId.toString(), 'session-server');
expect(mockInvalidateCachedTools).toHaveBeenCalledWith({
userId: userId.toString(),
serverName: 'session-server',
});
});
test('should preserve multi-owned MCP servers', async () => {
const deletingUserId = new mongoose.Types.ObjectId();
const otherOwnerId = new mongoose.Types.ObjectId();
const soleServer = await MCPServer.create({
serverName: 'sole-server',
config: { title: 'Sole Server' },
author: deletingUserId,
});
const multiServer = await MCPServer.create({
serverName: 'multi-server',
config: { title: 'Multi Server' },
author: deletingUserId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: deletingUserId,
resourceType: ResourceType.MCPSERVER,
resourceId: soleServer._id,
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
grantedBy: deletingUserId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: deletingUserId,
resourceType: ResourceType.MCPSERVER,
resourceId: multiServer._id,
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
grantedBy: deletingUserId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: otherOwnerId,
resourceType: ResourceType.MCPSERVER,
resourceId: multiServer._id,
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
grantedBy: otherOwnerId,
});
mockGetMCPManager.mockReturnValue({
disconnectUserConnection: jest.fn().mockResolvedValue(undefined),
});
await deleteUserMcpServers(deletingUserId.toString());
expect(await MCPServer.findById(soleServer._id)).toBeNull();
expect(await MCPServer.findById(multiServer._id)).not.toBeNull();
const soleAcl = await AclEntry.find({
resourceType: ResourceType.MCPSERVER,
resourceId: soleServer._id,
});
expect(soleAcl).toHaveLength(0);
const multiAclOther = await AclEntry.find({
resourceType: ResourceType.MCPSERVER,
resourceId: multiServer._id,
principalId: otherOwnerId,
});
expect(multiAclOther).toHaveLength(1);
expect(multiAclOther[0].permBits & PermissionBits.DELETE).toBeTruthy();
const multiAclDeleting = await AclEntry.find({
resourceType: ResourceType.MCPSERVER,
resourceId: multiServer._id,
principalId: deletingUserId,
});
expect(multiAclDeleting).toHaveLength(1);
});
test('should be a no-op when user has no owned MCP servers', async () => {
const userId = new mongoose.Types.ObjectId();
const otherUserId = new mongoose.Types.ObjectId();
const server = await MCPServer.create({
serverName: 'other-server',
config: { title: 'Other Server' },
author: otherUserId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: otherUserId,
resourceType: ResourceType.MCPSERVER,
resourceId: server._id,
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
grantedBy: otherUserId,
});
await deleteUserMcpServers(userId.toString());
expect(await MCPServer.findById(server._id)).not.toBeNull();
expect(mockGetMCPManager).not.toHaveBeenCalled();
});
test('should handle gracefully when MCPServer model is not registered', async () => {
const originalModel = mongoose.models.MCPServer;
delete mongoose.models.MCPServer;
try {
const userId = new mongoose.Types.ObjectId();
await expect(deleteUserMcpServers(userId.toString())).resolves.toBeUndefined();
} finally {
mongoose.models.MCPServer = originalModel;
}
});
test('should handle gracefully when MCPManager is not available', async () => {
const userId = new mongoose.Types.ObjectId();
const server = await MCPServer.create({
serverName: 'no-manager-server',
config: { title: 'No Manager Server' },
author: userId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: server._id,
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
grantedBy: userId,
});
mockGetMCPManager.mockReturnValue(null);
await deleteUserMcpServers(userId.toString());
expect(await MCPServer.findById(server._id)).toBeNull();
});
test('should delete legacy MCP servers that have author but no ACL entries', async () => {
const legacyUserId = new mongoose.Types.ObjectId();
const legacyServer = await MCPServer.create({
serverName: 'legacy-server',
config: { title: 'Legacy Server' },
author: legacyUserId,
});
mockGetMCPManager.mockReturnValue({
disconnectUserConnection: jest.fn().mockResolvedValue(undefined),
});
await deleteUserMcpServers(legacyUserId.toString());
expect(await MCPServer.findById(legacyServer._id)).toBeNull();
});
test('should delete both ACL-owned and legacy servers in one call', async () => {
const userId = new mongoose.Types.ObjectId();
const aclServer = await MCPServer.create({
serverName: 'acl-server',
config: { title: 'ACL Server' },
author: userId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: aclServer._id,
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
grantedBy: userId,
});
const legacyServer = await MCPServer.create({
serverName: 'legacy-mixed-server',
config: { title: 'Legacy Mixed' },
author: userId,
});
mockGetMCPManager.mockReturnValue({
disconnectUserConnection: jest.fn().mockResolvedValue(undefined),
});
await deleteUserMcpServers(userId.toString());
expect(await MCPServer.findById(aclServer._id)).toBeNull();
expect(await MCPServer.findById(legacyServer._id)).toBeNull();
});
});

View file

@ -0,0 +1,53 @@
const fs = require('fs');
const path = require('path');
const { ResourceType } = require('librechat-data-provider');
/**
* Maps each ResourceType to the cleanup function name that must appear in
* deleteUserController's source to prove it is handled during user deletion.
*
* When a new ResourceType is added, this test will fail until a corresponding
* entry is added here (or to NO_USER_CLEANUP_NEEDED) AND the actual cleanup
* logic is implemented.
*/
const HANDLED_RESOURCE_TYPES = {
[ResourceType.AGENT]: 'deleteUserAgents',
[ResourceType.REMOTE_AGENT]: 'deleteUserAgents',
[ResourceType.PROMPTGROUP]: 'deleteUserPrompts',
[ResourceType.MCPSERVER]: 'deleteUserMcpServers',
};
/**
* ResourceTypes that are ACL-tracked but have no per-user deletion semantics
* (e.g., system resources, public-only). Must be explicitly listed here with
* a justification to prevent silent omissions.
*/
const NO_USER_CLEANUP_NEEDED = new Set([
// Example: ResourceType.SYSTEM_TEMPLATE — public/system; not user-owned
]);
describe('deleteUserController - resource type coverage guard', () => {
let controllerSource;
beforeAll(() => {
controllerSource = fs.readFileSync(path.resolve(__dirname, '../UserController.js'), 'utf-8');
});
test('every ResourceType must have a documented cleanup handler or explicit exclusion', () => {
const allTypes = Object.values(ResourceType);
const handledTypes = Object.keys(HANDLED_RESOURCE_TYPES);
const unhandledTypes = allTypes.filter(
(t) => !handledTypes.includes(t) && !NO_USER_CLEANUP_NEEDED.has(t),
);
expect(unhandledTypes).toEqual([]);
});
test('every cleanup handler referenced in HANDLED_RESOURCE_TYPES must appear in the controller source', () => {
const uniqueHandlers = [...new Set(Object.values(HANDLED_RESOURCE_TYPES))];
for (const handler of uniqueHandlers) {
expect(controllerSource).toContain(handler);
}
});
});

View file

@ -3,6 +3,7 @@
* Tests that recordCollectedUsage is called correctly for token spending
*/
const mockProcessStream = jest.fn().mockResolvedValue(undefined);
const mockSpendTokens = jest.fn().mockResolvedValue({});
const mockSpendStructuredTokens = jest.fn().mockResolvedValue({});
const mockRecordCollectedUsage = jest
@ -35,7 +36,7 @@ jest.mock('@librechat/agents', () => ({
jest.mock('@librechat/api', () => ({
writeSSE: jest.fn(),
createRun: jest.fn().mockResolvedValue({
processStream: jest.fn().mockResolvedValue(undefined),
processStream: mockProcessStream,
}),
createChunk: jest.fn().mockReturnValue({}),
buildToolSet: jest.fn().mockReturnValue(new Set()),
@ -68,6 +69,7 @@ jest.mock('@librechat/api', () => ({
toolCalls: new Map(),
usage: { promptTokens: 100, completionTokens: 50, reasoningTokens: 0 },
}),
resolveRecursionLimit: jest.fn().mockReturnValue(50),
createToolExecuteHandler: jest.fn().mockReturnValue({ handle: jest.fn() }),
isChatCompletionValidationFailure: jest.fn().mockReturnValue(false),
}));
@ -77,43 +79,25 @@ jest.mock('~/server/services/ToolService', () => ({
loadToolsForExecution: jest.fn().mockResolvedValue([]),
}));
jest.mock('~/models/spendTokens', () => ({
spendTokens: mockSpendTokens,
spendStructuredTokens: mockSpendStructuredTokens,
}));
const mockGetMultiplier = jest.fn().mockReturnValue(1);
const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
jest.mock('~/models/tx', () => ({
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
}));
jest.mock('~/server/controllers/agents/callbacks', () => ({
createToolEndCallback: jest.fn().mockReturnValue(jest.fn()),
buildSummarizationHandlers: jest.fn().mockReturnValue({}),
markSummarizationUsage: jest.fn().mockImplementation((usage) => usage),
agentLogHandlerObj: { handle: jest.fn() },
}));
jest.mock('~/server/services/PermissionService', () => ({
findAccessibleResources: jest.fn().mockResolvedValue([]),
}));
jest.mock('~/models/Conversation', () => ({
getConvoFiles: jest.fn().mockResolvedValue([]),
getConvo: jest.fn().mockResolvedValue(null),
}));
jest.mock('~/models/Agent', () => ({
getAgent: jest.fn().mockResolvedValue({
id: 'agent-123',
provider: 'openAI',
model_parameters: { model: 'gpt-4' },
}),
getAgents: jest.fn().mockResolvedValue([]),
}));
const mockUpdateBalance = jest.fn().mockResolvedValue({});
const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined);
jest.mock('~/models', () => ({
getAgent: jest.fn().mockResolvedValue({ id: 'agent-123', name: 'Test Agent' }),
getFiles: jest.fn(),
getUserKey: jest.fn(),
getMessages: jest.fn(),
@ -124,6 +108,12 @@ jest.mock('~/models', () => ({
getCodeGeneratedFiles: jest.fn(),
updateBalance: mockUpdateBalance,
bulkInsertTransactions: mockBulkInsertTransactions,
spendTokens: mockSpendTokens,
spendStructuredTokens: mockSpendStructuredTokens,
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
getConvoFiles: jest.fn().mockResolvedValue([]),
getConvo: jest.fn().mockResolvedValue(null),
}));
describe('OpenAIChatCompletionController', () => {
@ -163,7 +153,7 @@ describe('OpenAIChatCompletionController', () => {
describe('conversation ownership validation', () => {
it('should skip ownership check when conversation_id is not provided', async () => {
const { getConvo } = require('~/models/Conversation');
const { getConvo } = require('~/models');
await OpenAIChatCompletionController(req, res);
expect(getConvo).not.toHaveBeenCalled();
});
@ -180,7 +170,7 @@ describe('OpenAIChatCompletionController', () => {
it('should return 404 when conversation is not owned by user', async () => {
const { validateRequest } = require('@librechat/api');
const { getConvo } = require('~/models/Conversation');
const { getConvo } = require('~/models');
validateRequest.mockReturnValueOnce({
request: {
model: 'agent-123',
@ -198,7 +188,7 @@ describe('OpenAIChatCompletionController', () => {
it('should proceed when conversation is owned by user', async () => {
const { validateRequest } = require('@librechat/api');
const { getConvo } = require('~/models/Conversation');
const { getConvo } = require('~/models');
validateRequest.mockReturnValueOnce({
request: {
model: 'agent-123',
@ -216,7 +206,7 @@ describe('OpenAIChatCompletionController', () => {
it('should return 500 when getConvo throws a DB error', async () => {
const { validateRequest } = require('@librechat/api');
const { getConvo } = require('~/models/Conversation');
const { getConvo } = require('~/models');
validateRequest.mockReturnValueOnce({
request: {
model: 'agent-123',
@ -298,4 +288,36 @@ describe('OpenAIChatCompletionController', () => {
);
});
});
describe('recursionLimit resolution', () => {
it('should pass resolveRecursionLimit result to processStream config', async () => {
const { resolveRecursionLimit } = require('@librechat/api');
resolveRecursionLimit.mockReturnValueOnce(75);
await OpenAIChatCompletionController(req, res);
expect(mockProcessStream).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ recursionLimit: 75 }),
expect.anything(),
);
});
it('should call resolveRecursionLimit with agentsEConfig and agent', async () => {
const { resolveRecursionLimit } = require('@librechat/api');
const { getAgent } = require('~/models');
const mockAgent = { id: 'agent-123', name: 'Test', recursion_limit: 200 };
getAgent.mockResolvedValueOnce(mockAgent);
req.config = {
endpoints: {
agents: { recursionLimit: 100, maxRecursionLimit: 150, allowedProviders: [] },
},
};
await OpenAIChatCompletionController(req, res);
expect(resolveRecursionLimit).toHaveBeenCalledWith(req.config.endpoints.agents, mockAgent);
});
});
});

View file

@ -101,46 +101,33 @@ jest.mock('~/server/services/ToolService', () => ({
loadToolsForExecution: jest.fn().mockResolvedValue([]),
}));
jest.mock('~/models/spendTokens', () => ({
spendTokens: mockSpendTokens,
spendStructuredTokens: mockSpendStructuredTokens,
}));
const mockGetMultiplier = jest.fn().mockReturnValue(1);
const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
jest.mock('~/models/tx', () => ({
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
}));
jest.mock('~/server/controllers/agents/callbacks', () => ({
createToolEndCallback: jest.fn().mockReturnValue(jest.fn()),
createResponsesToolEndCallback: jest.fn().mockReturnValue(jest.fn()),
}));
jest.mock('~/server/controllers/agents/callbacks', () => {
const noop = { handle: jest.fn() };
return {
createToolEndCallback: jest.fn().mockReturnValue(jest.fn()),
createResponsesToolEndCallback: jest.fn().mockReturnValue(jest.fn()),
markSummarizationUsage: jest.fn().mockImplementation((usage) => usage),
agentLogHandlerObj: noop,
buildSummarizationHandlers: jest.fn().mockReturnValue({
on_summarize_start: noop,
on_summarize_delta: noop,
on_summarize_complete: noop,
}),
};
});
jest.mock('~/server/services/PermissionService', () => ({
findAccessibleResources: jest.fn().mockResolvedValue([]),
}));
jest.mock('~/models/Conversation', () => ({
getConvoFiles: jest.fn().mockResolvedValue([]),
saveConvo: jest.fn().mockResolvedValue({}),
getConvo: jest.fn().mockResolvedValue(null),
}));
jest.mock('~/models/Agent', () => ({
getAgent: jest.fn().mockResolvedValue({
id: 'agent-123',
name: 'Test Agent',
provider: 'anthropic',
model_parameters: { model: 'claude-3' },
}),
getAgents: jest.fn().mockResolvedValue([]),
}));
const mockUpdateBalance = jest.fn().mockResolvedValue({});
const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined);
jest.mock('~/models', () => ({
getAgent: jest.fn().mockResolvedValue({ id: 'agent-123', name: 'Test Agent' }),
getFiles: jest.fn(),
getUserKey: jest.fn(),
getMessages: jest.fn().mockResolvedValue([]),
@ -152,6 +139,13 @@ jest.mock('~/models', () => ({
getCodeGeneratedFiles: jest.fn(),
updateBalance: mockUpdateBalance,
bulkInsertTransactions: mockBulkInsertTransactions,
spendTokens: mockSpendTokens,
spendStructuredTokens: mockSpendStructuredTokens,
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
getConvoFiles: jest.fn().mockResolvedValue([]),
saveConvo: jest.fn().mockResolvedValue({}),
getConvo: jest.fn().mockResolvedValue(null),
}));
describe('createResponse controller', () => {
@ -191,7 +185,7 @@ describe('createResponse controller', () => {
describe('conversation ownership validation', () => {
it('should skip ownership check when previous_response_id is not provided', async () => {
const { getConvo } = require('~/models/Conversation');
const { getConvo } = require('~/models');
await createResponse(req, res);
expect(getConvo).not.toHaveBeenCalled();
});
@ -218,7 +212,7 @@ describe('createResponse controller', () => {
it('should return 404 when conversation is not owned by user', async () => {
const { validateResponseRequest, sendResponsesErrorResponse } = require('@librechat/api');
const { getConvo } = require('~/models/Conversation');
const { getConvo } = require('~/models');
validateResponseRequest.mockReturnValueOnce({
request: {
model: 'agent-123',
@ -241,7 +235,7 @@ describe('createResponse controller', () => {
it('should proceed when conversation is owned by user', async () => {
const { validateResponseRequest, sendResponsesErrorResponse } = require('@librechat/api');
const { getConvo } = require('~/models/Conversation');
const { getConvo } = require('~/models');
validateResponseRequest.mockReturnValueOnce({
request: {
model: 'agent-123',
@ -264,7 +258,7 @@ describe('createResponse controller', () => {
it('should return 500 when getConvo throws a DB error', async () => {
const { validateResponseRequest, sendResponsesErrorResponse } = require('@librechat/api');
const { getConvo } = require('~/models/Conversation');
const { getConvo } = require('~/models');
validateResponseRequest.mockReturnValueOnce({
request: {
model: 'agent-123',
@ -386,28 +380,7 @@ describe('createResponse controller', () => {
it('should collect usage from on_chat_model_end events', async () => {
const api = require('@librechat/api');
let capturedOnChatModelEnd;
api.createAggregatorEventHandlers.mockImplementation(() => {
return {
on_message_delta: { handle: jest.fn() },
on_reasoning_delta: { handle: jest.fn() },
on_run_step: { handle: jest.fn() },
on_run_step_delta: { handle: jest.fn() },
on_chat_model_end: {
handle: jest.fn((event, data) => {
if (capturedOnChatModelEnd) {
capturedOnChatModelEnd(event, data);
}
}),
},
};
});
api.createRun.mockImplementation(async ({ customHandlers }) => {
capturedOnChatModelEnd = (event, data) => {
customHandlers.on_chat_model_end.handle(event, data);
};
return {
processStream: jest.fn().mockImplementation(async () => {
customHandlers.on_chat_model_end.handle('on_chat_model_end', {
@ -424,7 +397,6 @@ describe('createResponse controller', () => {
});
await createResponse(req, res);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({

View file

@ -1,10 +1,8 @@
const { duplicateAgent } = require('../v1');
const { getAgent, createAgent } = require('~/models/Agent');
const { getActions } = require('~/models/Action');
const { getAgent, createAgent, getActions } = require('~/models');
const { nanoid } = require('nanoid');
jest.mock('~/models/Agent');
jest.mock('~/models/Action');
jest.mock('~/models');
jest.mock('nanoid');
describe('duplicateAgent', () => {

View file

@ -1,7 +1,13 @@
const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const { Constants, EnvVar, GraphEvents, ToolEndHandler } = require('@librechat/agents');
const { Tools, StepTypes, FileContext, ErrorTypes } = require('librechat-data-provider');
const {
EnvVar,
Constants,
GraphEvents,
GraphNodeKeys,
ToolEndHandler,
} = require('@librechat/agents');
const {
sendEvent,
GenerationJobManager,
@ -71,7 +77,9 @@ class ModelEndHandler {
usage.model = modelName;
}
this.collectedUsage.push(usage);
const taggedUsage = markSummarizationUsage(usage, metadata);
this.collectedUsage.push(taggedUsage);
} catch (error) {
logger.error('Error handling model end event:', error);
return this.finalize(errorMessage);
@ -133,6 +141,7 @@ function getDefaultHandlers({
collectedUsage,
streamId = null,
toolExecuteOptions = null,
summarizationOptions = null,
}) {
if (!res || !aggregateContent) {
throw new Error(
@ -245,6 +254,37 @@ function getDefaultHandlers({
handlers[GraphEvents.ON_TOOL_EXECUTE] = createToolExecuteHandler(toolExecuteOptions);
}
if (summarizationOptions?.enabled !== false) {
handlers[GraphEvents.ON_SUMMARIZE_START] = {
handle: async (_event, data) => {
await emitEvent(res, streamId, {
event: GraphEvents.ON_SUMMARIZE_START,
data,
});
},
};
handlers[GraphEvents.ON_SUMMARIZE_DELTA] = {
handle: async (_event, data) => {
aggregateContent({ event: GraphEvents.ON_SUMMARIZE_DELTA, data });
await emitEvent(res, streamId, {
event: GraphEvents.ON_SUMMARIZE_DELTA,
data,
});
},
};
handlers[GraphEvents.ON_SUMMARIZE_COMPLETE] = {
handle: async (_event, data) => {
aggregateContent({ event: GraphEvents.ON_SUMMARIZE_COMPLETE, data });
await emitEvent(res, streamId, {
event: GraphEvents.ON_SUMMARIZE_COMPLETE,
data,
});
},
};
}
handlers[GraphEvents.ON_AGENT_LOG] = { handle: agentLogHandler };
return handlers;
}
@ -668,8 +708,62 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises })
};
}
const ALLOWED_LOG_LEVELS = new Set(['debug', 'info', 'warn', 'error']);
function agentLogHandler(_event, data) {
if (!data) {
return;
}
const logFn = ALLOWED_LOG_LEVELS.has(data.level) ? logger[data.level] : logger.debug;
const meta = typeof data.data === 'object' && data.data != null ? data.data : {};
logFn(`[agents:${data.scope ?? 'unknown'}] ${data.message ?? ''}`, {
...meta,
runId: data.runId,
agentId: data.agentId,
});
}
function markSummarizationUsage(usage, metadata) {
const node = metadata?.langgraph_node;
if (typeof node === 'string' && node.startsWith(GraphNodeKeys.SUMMARIZE)) {
return { ...usage, usage_type: 'summarization' };
}
return usage;
}
const agentLogHandlerObj = { handle: agentLogHandler };
/**
* Builds the three summarization SSE event handlers.
* In streaming mode, each event is forwarded to the client via `res.write`.
* In non-streaming mode, the handlers are no-ops.
* @param {{ isStreaming: boolean, res: import('express').Response }} opts
*/
function buildSummarizationHandlers({ isStreaming, res }) {
if (!isStreaming) {
const noop = { handle: () => {} };
return { on_summarize_start: noop, on_summarize_delta: noop, on_summarize_complete: noop };
}
const writeEvent = (name) => ({
handle: async (_event, data) => {
if (!res.writableEnded) {
res.write(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`);
}
},
});
return {
on_summarize_start: writeEvent('on_summarize_start'),
on_summarize_delta: writeEvent('on_summarize_delta'),
on_summarize_complete: writeEvent('on_summarize_complete'),
};
}
module.exports = {
agentLogHandler,
agentLogHandlerObj,
getDefaultHandlers,
createToolEndCallback,
markSummarizationUsage,
buildSummarizationHandlers,
createResponsesToolEndCallback,
};

View file

@ -3,11 +3,11 @@ const { logger } = require('@librechat/data-schemas');
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
const {
createRun,
Tokenizer,
isEnabled,
checkAccess,
buildToolSet,
sanitizeTitle,
logToolError,
sanitizeTitle,
payloadParser,
resolveHeaders,
createSafeUser,
@ -21,9 +21,13 @@ const {
recordCollectedUsage,
GenerationJobManager,
getTransactionsConfig,
resolveRecursionLimit,
createMemoryProcessor,
loadAgent: loadAgentFn,
createMultiAgentMapper,
filterMalformedContentParts,
countFormattedMessageTokens,
hydrateMissingIndexTokenCounts,
} = require('@librechat/api');
const {
Callback,
@ -45,18 +49,16 @@ const {
removeNullishValues,
} = require('librechat-data-provider');
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const { updateBalance, bulkInsertTransactions } = require('~/models');
const { getMultiplier, getCacheMultiplier } = require('~/models/tx');
const { createContextHandlers } = require('~/app/clients/prompts');
const { getConvoFiles } = require('~/models/Conversation');
const { resolveConfigServers } = require('~/server/services/MCP');
const { getMCPServerTools } = require('~/server/services/Config');
const BaseClient = require('~/app/clients/BaseClient');
const { getRoleByName } = require('~/models/Role');
const { loadAgent } = require('~/models/Agent');
const { getMCPManager } = require('~/config');
const db = require('~/models');
const loadAgent = (params) => loadAgentFn(params, { getAgent: db.getAgent, getMCPServerTools });
class AgentClient extends BaseClient {
constructor(options = {}) {
super(null, options);
@ -64,9 +66,6 @@ class AgentClient extends BaseClient {
* @type {string} */
this.clientName = EModelEndpoint.agents;
/** @type {'discard' | 'summarize'} */
this.contextStrategy = 'discard';
/** @deprecated @type {true} - Is a Chat Completion Request */
this.isChatCompletion = true;
@ -218,7 +217,6 @@ class AgentClient extends BaseClient {
}))
: []),
];
if (this.options.attachments) {
const attachments = await this.options.attachments;
const latestMessage = orderedMessages[orderedMessages.length - 1];
@ -245,6 +243,11 @@ class AgentClient extends BaseClient {
);
}
/** @type {Record<number, number>} */
const canonicalTokenCountMap = {};
/** @type {Record<string, number>} */
const tokenCountMap = {};
let promptTokenTotal = 0;
const formattedMessages = orderedMessages.map((message, i) => {
const formattedMessage = formatMessage({
message,
@ -264,12 +267,14 @@ class AgentClient extends BaseClient {
}
}
const needsTokenCount =
(this.contextStrategy && !orderedMessages[i].tokenCount) || message.fileContext;
const dbTokenCount = orderedMessages[i].tokenCount;
const needsTokenCount = !dbTokenCount || message.fileContext;
/* 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);
orderedMessages[i].tokenCount = countFormattedMessageTokens(
formattedMessage,
this.getEncoding(),
);
}
/* If message has files, calculate image token cost */
@ -283,17 +288,37 @@ class AgentClient extends BaseClient {
if (file.metadata?.fileIdentifier) {
continue;
}
// orderedMessages[i].tokenCount += this.calculateImageTokenCost({
// width: file.width,
// height: file.height,
// detail: this.options.imageDetail ?? ImageDetail.auto,
// });
}
}
const tokenCount = Number(orderedMessages[i].tokenCount);
const normalizedTokenCount = Number.isFinite(tokenCount) && tokenCount > 0 ? tokenCount : 0;
canonicalTokenCountMap[i] = normalizedTokenCount;
promptTokenTotal += normalizedTokenCount;
if (message.messageId) {
tokenCountMap[message.messageId] = normalizedTokenCount;
}
if (isEnabled(process.env.AGENT_DEBUG_LOGGING)) {
const role = message.isCreatedByUser ? 'user' : 'assistant';
const hasSummary =
Array.isArray(message.content) && message.content.some((p) => p && p.type === 'summary');
const suffix = hasSummary ? '[S]' : '';
const id = (message.messageId ?? message.id ?? '').slice(-8);
const recalced = needsTokenCount ? orderedMessages[i].tokenCount : null;
logger.debug(
`[AgentClient] msg[${i}] ${role}${suffix} id=…${id} db=${dbTokenCount} needsRecount=${needsTokenCount} recalced=${recalced} tokens=${normalizedTokenCount}`,
);
}
return formattedMessage;
});
payload = formattedMessages;
messages = orderedMessages;
promptTokens = promptTokenTotal;
/**
* Build shared run context - applies to ALL agents in the run.
* This includes: file context (latest message), augmented prompt (RAG), memory context.
@ -323,23 +348,20 @@ class AgentClient extends BaseClient {
const sharedRunContext = sharedRunContextParts.join('\n\n');
/** @type {Record<string, number> | undefined} */
let tokenCountMap;
/** Preserve canonical pre-format token counts for all history entering graph formatting */
this.indexTokenCountMap = canonicalTokenCountMap;
if (this.contextStrategy) {
({ payload, promptTokens, tokenCountMap, messages } = await this.handleContextStrategy({
orderedMessages,
formattedMessages,
}));
}
for (let i = 0; i < messages.length; i++) {
this.indexTokenCountMap[i] = messages[i].tokenCount;
/** Extract contextMeta from the parent response (second-to-last in ordered chain;
* last is the current user message). Seeds the pruner's calibration EMA for this run. */
const parentResponse =
orderedMessages.length >= 2 ? orderedMessages[orderedMessages.length - 2] : undefined;
if (parentResponse?.contextMeta && !parentResponse.isCreatedByUser) {
this.contextMeta = parentResponse.contextMeta;
}
const result = {
tokenCountMap,
prompt: payload,
tokenCountMap,
promptTokens,
messages,
};
@ -357,6 +379,9 @@ class AgentClient extends BaseClient {
*/
const ephemeralAgent = this.options.req.body.ephemeralAgent;
const mcpManager = getMCPManager();
const configServers = await resolveConfigServers(this.options.req);
await Promise.all(
allAgents.map(({ agent, agentId }) =>
applyContextToAgent({
@ -364,6 +389,7 @@ class AgentClient extends BaseClient {
agentId,
logger,
mcpManager,
configServers,
sharedRunContext,
ephemeralAgent: agentId === this.options.agent.id ? ephemeralAgent : undefined,
}),
@ -413,7 +439,7 @@ class AgentClient extends BaseClient {
user,
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE],
getRoleByName,
getRoleByName: db.getRoleByName,
});
if (!hasAccess) {
@ -473,9 +499,9 @@ class AgentClient extends BaseClient {
},
},
{
getConvoFiles,
getFiles: db.getFiles,
getUserKey: db.getUserKey,
getConvoFiles: db.getConvoFiles,
updateFilesUsage: db.updateFilesUsage,
getUserKeyValues: db.getUserKeyValues,
getToolFilesByIds: db.getToolFilesByIds,
@ -631,10 +657,10 @@ class AgentClient extends BaseClient {
}) {
const result = await recordCollectedUsage(
{
spendTokens,
spendStructuredTokens,
pricing: { getMultiplier, getCacheMultiplier },
bulkWriteOps: { insertMany: bulkInsertTransactions, updateBalance },
spendTokens: db.spendTokens,
spendStructuredTokens: db.spendStructuredTokens,
pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
},
{
user: this.user ?? this.options.req.user?.id,
@ -667,39 +693,7 @@ class AgentClient extends BaseClient {
* @returns {number}
*/
getTokenCountForResponse({ content }) {
return this.getTokenCountForMessage({
role: 'assistant',
content,
});
}
/**
* Calculates the correct token count for the current user message based on the token count map and API usage.
* Edge case: If the calculation results in a negative value, it returns the original estimate.
* If revisiting a conversation with a chat history entirely composed of token estimates,
* the cumulative token count going forward should become more accurate as the conversation progresses.
* @param {Object} params - The parameters for the calculation.
* @param {Record<string, number>} params.tokenCountMap - A map of message IDs to their token counts.
* @param {string} params.currentMessageId - The ID of the current message to calculate.
* @param {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;
return countFormattedMessageTokens({ role: 'assistant', content }, this.getEncoding());
}
/**
@ -740,18 +734,41 @@ class AgentClient extends BaseClient {
},
user: createSafeUser(this.options.req.user),
},
recursionLimit: agentsEConfig?.recursionLimit ?? 50,
recursionLimit: resolveRecursionLimit(agentsEConfig, this.options.agent),
signal: abortController.signal,
streamMode: 'values',
version: 'v2',
};
const toolSet = buildToolSet(this.options.agent);
let { messages: initialMessages, indexTokenCountMap } = formatAgentMessages(
payload,
this.indexTokenCountMap,
toolSet,
);
const tokenCounter = createTokenCounter(this.getEncoding());
let {
messages: initialMessages,
indexTokenCountMap,
summary: initialSummary,
boundaryTokenAdjustment,
} = formatAgentMessages(payload, this.indexTokenCountMap, toolSet);
if (boundaryTokenAdjustment) {
logger.debug(
`[AgentClient] Boundary token adjustment: ${boundaryTokenAdjustment.original}${boundaryTokenAdjustment.adjusted} (${boundaryTokenAdjustment.remainingChars}/${boundaryTokenAdjustment.totalChars} chars)`,
);
}
if (indexTokenCountMap && isEnabled(process.env.AGENT_DEBUG_LOGGING)) {
const entries = Object.entries(indexTokenCountMap);
const perMsg = entries.map(([idx, count]) => {
const msg = initialMessages[Number(idx)];
const type = msg ? msg._getType() : '?';
return `${idx}:${type}=${count}`;
});
logger.debug(
`[AgentClient] Token map after format: [${perMsg.join(', ')}] (payload=${payload.length}, formatted=${initialMessages.length})`,
);
}
indexTokenCountMap = hydrateMissingIndexTokenCounts({
messages: initialMessages,
indexTokenCountMap,
tokenCounter,
});
/**
* @param {BaseMessage[]} messages
@ -765,17 +782,6 @@ class AgentClient extends BaseClient {
agents.push(...this.agentConfigs.values());
}
if (agents[0].recursion_limit && typeof agents[0].recursion_limit === 'number') {
config.recursionLimit = agents[0].recursion_limit;
}
if (
agentsEConfig?.maxRecursionLimit &&
config.recursionLimit > agentsEConfig?.maxRecursionLimit
) {
config.recursionLimit = agentsEConfig?.maxRecursionLimit;
}
// TODO: needs to be added as part of AgentContext initialization
// const noSystemModelRegex = [/\b(o1-preview|o1-mini|amazon\.titan-text)\b/gi];
// const noSystemMessages = noSystemModelRegex.some((regex) =>
@ -805,16 +811,32 @@ class AgentClient extends BaseClient {
memoryPromise = this.runMemory(messages);
/** Seed calibration state from previous run if encoding matches */
const currentEncoding = this.getEncoding();
const prevMeta = this.contextMeta;
const encodingMatch = prevMeta?.encoding === currentEncoding;
const calibrationRatio =
encodingMatch && prevMeta?.calibrationRatio > 0 ? prevMeta.calibrationRatio : undefined;
if (prevMeta) {
logger.debug(
`[AgentClient] contextMeta from parent: ratio=${prevMeta.calibrationRatio}, encoding=${prevMeta.encoding}, current=${currentEncoding}, seeded=${calibrationRatio ?? 'none'}`,
);
}
run = await createRun({
agents,
messages,
indexTokenCountMap,
initialSummary,
calibrationRatio,
runId: this.responseMessageId,
signal: abortController.signal,
customHandlers: this.options.eventHandlers,
requestBody: config.configurable.requestBody,
user: createSafeUser(this.options.req?.user),
tokenCounter: createTokenCounter(this.getEncoding()),
summarizationConfig: appConfig?.summarization,
tokenCounter,
});
if (!run) {
@ -845,6 +867,7 @@ class AgentClient extends BaseClient {
const hideSequentialOutputs = config.configurable.hide_sequential_outputs;
await runAgents(initialMessages);
/** @deprecated Agent Chain */
if (hideSequentialOutputs) {
this.contentParts = this.contentParts.filter((part, index) => {
@ -875,6 +898,18 @@ class AgentClient extends BaseClient {
});
}
} finally {
/** Capture calibration state from the run for persistence on the response message.
* Runs in finally so values are captured even on abort. */
const ratio = this.run?.getCalibrationRatio() ?? 0;
if (ratio > 0 && ratio !== 1) {
this.contextMeta = {
calibrationRatio: Math.round(ratio * 1000) / 1000,
encoding: this.getEncoding(),
};
} else {
this.contextMeta = undefined;
}
try {
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
if (attachments && attachments.length > 0) {
@ -1060,6 +1095,7 @@ class AgentClient extends BaseClient {
titlePrompt: endpointConfig?.titlePrompt,
titlePromptTemplate: endpointConfig?.titlePromptTemplate,
chainOptions: {
runName: 'TitleRun',
signal: abortController.signal,
callbacks: [
{
@ -1134,7 +1170,7 @@ class AgentClient extends BaseClient {
context = 'message',
}) {
try {
await spendTokens(
await db.spendTokens(
{
model,
context,
@ -1153,7 +1189,7 @@ class AgentClient extends BaseClient {
'reasoning_tokens' in usage &&
typeof usage.reasoning_tokens === 'number'
) {
await spendTokens(
await db.spendTokens(
{
model,
balance,
@ -1181,16 +1217,6 @@ class AgentClient extends BaseClient {
}
return 'o200k_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);
}
}
module.exports = AgentClient;

View file

@ -15,13 +15,19 @@ jest.mock('@librechat/api', () => ({
checkAccess: jest.fn(),
initializeAgent: jest.fn(),
createMemoryProcessor: jest.fn(),
}));
jest.mock('~/models/Agent', () => ({
loadAgent: jest.fn(),
}));
jest.mock('~/models/Role', () => ({
jest.mock('~/server/services/Config', () => ({
getMCPServerTools: jest.fn(),
}));
jest.mock('~/server/services/MCP', () => ({
resolveConfigServers: jest.fn().mockResolvedValue({}),
}));
jest.mock('~/models', () => ({
getAgent: jest.fn(),
getRoleByName: jest.fn(),
}));
@ -1313,7 +1319,7 @@ describe('AgentClient - titleConvo', () => {
});
// Verify formatInstructionsForContext was called with correct server names
expect(mockFormatInstructions).toHaveBeenCalledWith(['server1', 'server2']);
expect(mockFormatInstructions).toHaveBeenCalledWith(['server1', 'server2'], {});
// Verify the instructions do NOT contain [object Promise]
expect(client.options.agent.instructions).not.toContain('[object Promise]');
@ -1353,10 +1359,10 @@ describe('AgentClient - titleConvo', () => {
});
// Verify formatInstructionsForContext was called with ephemeral server names
expect(mockFormatInstructions).toHaveBeenCalledWith([
'ephemeral-server1',
'ephemeral-server2',
]);
expect(mockFormatInstructions).toHaveBeenCalledWith(
['ephemeral-server1', 'ephemeral-server2'],
{},
);
// Verify no [object Promise] in instructions
expect(client.options.agent.instructions).not.toContain('[object Promise]');
@ -1816,7 +1822,7 @@ describe('AgentClient - titleConvo', () => {
/** Traversal stops at msg-2 (has summary), so we get msg-4 -> msg-3 -> msg-2 */
expect(result).toHaveLength(3);
expect(result[0].text).toBe('Summary of conversation');
expect(result[0].content).toEqual([{ type: 'text', text: 'Summary of conversation' }]);
expect(result[0].role).toBe('system');
expect(result[0].mapped).toBe(true);
expect(result[1].mapped).toBe(true);
@ -2138,7 +2144,7 @@ describe('AgentClient - titleConvo', () => {
};
mockCheckAccess = require('@librechat/api').checkAccess;
mockLoadAgent = require('~/models/Agent').loadAgent;
mockLoadAgent = require('@librechat/api').loadAgent;
mockInitializeAgent = require('@librechat/api').initializeAgent;
mockCreateMemoryProcessor = require('@librechat/api').createMemoryProcessor;
});
@ -2195,6 +2201,7 @@ describe('AgentClient - titleConvo', () => {
expect.objectContaining({
agent_id: differentAgentId,
}),
expect.any(Object),
);
expect(mockInitializeAgent).toHaveBeenCalledWith(
expect.objectContaining({

View file

@ -3,8 +3,8 @@ const { logger } = require('@librechat/data-schemas');
const { CacheKeys, ViolationTypes } = require('librechat-data-provider');
const { sendResponse } = require('~/server/middleware/error');
const { recordUsage } = require('~/server/services/Threads');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const { getConvo } = require('~/models');
/**
* @typedef {Object} ErrorHandlerContext

View file

@ -22,8 +22,8 @@ jest.mock('~/config', () => ({
})),
}));
jest.mock('~/models/Project', () => ({
getProjectByName: jest.fn().mockResolvedValue(null),
jest.mock('~/server/services/MCP', () => ({
resolveConfigServers: jest.fn().mockResolvedValue({}),
}));
jest.mock('~/server/services/Files/strategies', () => ({
@ -34,23 +34,10 @@ jest.mock('~/server/services/Files/images/avatar', () => ({
resizeAvatar: jest.fn(),
}));
jest.mock('~/server/services/Files/S3/crud', () => ({
refreshS3Url: jest.fn(),
}));
jest.mock('~/server/services/Files/process', () => ({
filterFile: jest.fn(),
}));
jest.mock('~/models/Action', () => ({
updateAction: jest.fn(),
getActions: jest.fn().mockResolvedValue([]),
}));
jest.mock('~/models/File', () => ({
deleteFileByFilter: jest.fn(),
}));
jest.mock('~/server/services/PermissionService', () => ({
findAccessibleResources: jest.fn().mockResolvedValue([]),
findPubliclyAccessibleResources: jest.fn().mockResolvedValue([]),
@ -59,9 +46,17 @@ jest.mock('~/server/services/PermissionService', () => ({
checkPermission: jest.fn().mockResolvedValue(true),
}));
jest.mock('~/models', () => ({
getCategoriesWithCounts: jest.fn(),
}));
jest.mock('~/models', () => {
const mongoose = require('mongoose');
const { createModels, createMethods } = require('@librechat/data-schemas');
createModels(mongoose);
const methods = createMethods(mongoose);
return {
...methods,
getCategoriesWithCounts: jest.fn(),
deleteFileByFilter: jest.fn(),
};
});
jest.mock('~/cache', () => ({
getLogStores: jest.fn(() => ({
@ -232,7 +227,27 @@ describe('MCP Tool Authorization', () => {
availableTools,
});
expect(mockGetAllServerConfigs).toHaveBeenCalledWith('specific-user-id');
expect(mockGetAllServerConfigs).toHaveBeenCalledWith('specific-user-id', undefined);
});
test('should pass configServers to getAllServerConfigs and allow config-override servers', async () => {
const configServers = {
'config-override-server': { type: 'sse', url: 'https://override.example.com' },
};
mockGetAllServerConfigs.mockResolvedValue({
'config-override-server': configServers['config-override-server'],
});
const result = await filterAuthorizedTools({
tools: [`tool${d}config-override-server`, `tool${d}unauthorizedServer`],
userId,
availableTools,
configServers,
});
expect(mockGetAllServerConfigs).toHaveBeenCalledWith(userId, configServers);
expect(result).toContain(`tool${d}config-override-server`);
expect(result).not.toContain(`tool${d}unauthorizedServer`);
});
test('should only call getAllServerConfigs once even with multiple MCP tools', async () => {

View file

@ -15,19 +15,21 @@ const {
createErrorResponse,
recordCollectedUsage,
getTransactionsConfig,
resolveRecursionLimit,
createToolExecuteHandler,
buildNonStreamingResponse,
createOpenAIStreamTracker,
createOpenAIContentAggregator,
isChatCompletionValidationFailure,
} = require('@librechat/api');
const {
buildSummarizationHandlers,
markSummarizationUsage,
createToolEndCallback,
agentLogHandlerObj,
} = require('~/server/controllers/agents/callbacks');
const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService');
const { createToolEndCallback } = require('~/server/controllers/agents/callbacks');
const { findAccessibleResources } = require('~/server/services/PermissionService');
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
const { getMultiplier, getCacheMultiplier } = require('~/models/tx');
const { getConvoFiles, getConvo } = require('~/models/Conversation');
const { getAgent, getAgents } = require('~/models/Agent');
const db = require('~/models');
/**
@ -139,7 +141,7 @@ const OpenAIChatCompletionController = async (req, res) => {
const agentId = request.model;
// Look up the agent
const agent = await getAgent({ id: agentId });
const agent = await db.getAgent({ id: agentId });
if (!agent) {
return sendErrorResponse(
res,
@ -185,7 +187,7 @@ const OpenAIChatCompletionController = async (req, res) => {
'invalid_request_error',
);
}
if (!(await getConvo(req.user?.id, request.conversation_id))) {
if (!(await db.getConvo(req.user?.id, request.conversation_id))) {
return sendErrorResponse(res, 404, 'Conversation not found', 'invalid_request_error');
}
}
@ -193,10 +195,8 @@ const OpenAIChatCompletionController = async (req, res) => {
const conversationId = request.conversation_id ?? nanoid();
const parentMessageId = request.parent_message_id ?? null;
// Build allowed providers set
const allowedProviders = new Set(
appConfig?.endpoints?.[EModelEndpoint.agents]?.allowedProviders,
);
const agentsEConfig = appConfig?.endpoints?.[EModelEndpoint.agents];
const allowedProviders = new Set(agentsEConfig?.allowedProviders);
// Create tool loader
const loadTools = createToolLoader(abortController.signal);
@ -221,7 +221,7 @@ const OpenAIChatCompletionController = async (req, res) => {
isInitialAgent: true,
},
{
getConvoFiles,
getConvoFiles: db.getConvoFiles,
getFiles: db.getFiles,
getUserKey: db.getUserKey,
getMessages: db.getMessages,
@ -286,14 +286,16 @@ const OpenAIChatCompletionController = async (req, res) => {
toolEndCallback,
};
const summarizationConfig = appConfig?.summarization;
const openaiMessages = convertMessages(request.messages);
const toolSet = buildToolSet(primaryConfig);
const { messages: formattedMessages, indexTokenCountMap } = formatAgentMessages(
openaiMessages,
{},
toolSet,
);
const {
messages: formattedMessages,
indexTokenCountMap,
summary: initialSummary,
} = formatAgentMessages(openaiMessages, {}, toolSet);
/**
* Create a simple handler that processes data
@ -436,24 +438,30 @@ const OpenAIChatCompletionController = async (req, res) => {
}),
// Usage tracking
on_chat_model_end: createHandler((data) => {
const usage = data?.output?.usage_metadata;
if (usage) {
collectedUsage.push(usage);
const target = isStreaming ? tracker : aggregator;
target.usage.promptTokens += usage.input_tokens ?? 0;
target.usage.completionTokens += usage.output_tokens ?? 0;
}
}),
on_chat_model_end: {
handle: (_event, data, metadata) => {
const usage = data?.output?.usage_metadata;
if (usage) {
const taggedUsage = markSummarizationUsage(usage, metadata);
collectedUsage.push(taggedUsage);
const target = isStreaming ? tracker : aggregator;
target.usage.promptTokens += taggedUsage.input_tokens ?? 0;
target.usage.completionTokens += taggedUsage.output_tokens ?? 0;
}
},
},
on_run_step_completed: createHandler(),
// Use proper ToolEndHandler for processing artifacts (images, file citations, code output)
on_tool_end: new ToolEndHandler(toolEndCallback, logger),
on_chain_stream: createHandler(),
on_chain_end: createHandler(),
on_agent_update: createHandler(),
on_agent_log: agentLogHandlerObj,
on_custom_event: createHandler(),
// Event-driven tool execution handler
on_tool_execute: createToolExecuteHandler(toolExecuteOptions),
...(summarizationConfig?.enabled !== false
? buildSummarizationHandlers({ isStreaming, res })
: {}),
};
// Create and run the agent
@ -466,7 +474,9 @@ const OpenAIChatCompletionController = async (req, res) => {
agents: [primaryConfig],
messages: formattedMessages,
indexTokenCountMap,
initialSummary,
runId: responseId,
summarizationConfig,
signal: abortController.signal,
customHandlers: handlers,
requestBody: {
@ -480,7 +490,6 @@ const OpenAIChatCompletionController = async (req, res) => {
throw new Error('Failed to create agent run');
}
// Process the stream
const config = {
runName: 'AgentRun',
configurable: {
@ -493,6 +502,7 @@ const OpenAIChatCompletionController = async (req, res) => {
},
...(userMCPAuthMap != null && { userMCPAuthMap }),
},
recursionLimit: resolveRecursionLimit(agentsEConfig, agent),
signal: abortController.signal,
streamMode: 'values',
version: 'v2',
@ -511,9 +521,9 @@ const OpenAIChatCompletionController = async (req, res) => {
const transactionsConfig = getTransactionsConfig(appConfig);
recordCollectedUsage(
{
spendTokens,
spendStructuredTokens,
pricing: { getMultiplier, getCacheMultiplier },
spendTokens: db.spendTokens,
spendStructuredTokens: db.spendStructuredTokens,
pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
},
{
@ -627,7 +637,7 @@ const ListModelsController = async (req, res) => {
// Get the accessible agents
let agents = [];
if (accessibleAgentIds.length > 0) {
agents = await getAgents({ _id: { $in: accessibleAgentIds } });
agents = await db.getAgents({ _id: { $in: accessibleAgentIds } });
}
const models = agents.map((agent) => ({
@ -670,7 +680,7 @@ const GetModelController = async (req, res) => {
return sendErrorResponse(res, 401, 'Authentication required', 'auth_error');
}
const agent = await getAgent({ id: model });
const agent = await db.getAgent({ id: model });
if (!agent) {
return sendErrorResponse(

View file

@ -18,17 +18,11 @@ const mockRecordCollectedUsage = jest
.fn()
.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
jest.mock('~/models/spendTokens', () => ({
jest.mock('~/models', () => ({
spendTokens: (...args) => mockSpendTokens(...args),
spendStructuredTokens: (...args) => mockSpendStructuredTokens(...args),
}));
jest.mock('~/models/tx', () => ({
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
}));
jest.mock('~/models', () => ({
updateBalance: mockUpdateBalance,
bulkInsertTransactions: mockBulkInsertTransactions,
}));

View file

@ -131,9 +131,15 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
partialMessage.agent_id = req.body.agent_id;
}
await saveMessage(req, partialMessage, {
context: 'api/server/controllers/agents/request.js - partial response on disconnect',
});
await saveMessage(
{
userId: req?.user?.id,
isTemporary: req?.body?.isTemporary,
interfaceConfig: req?.config?.interfaceConfig,
},
partialMessage,
{ context: 'api/server/controllers/agents/request.js - partial response on disconnect' },
);
logger.debug(
`[ResumableAgentController] Saved partial response for ${streamId}, content parts: ${aggregatedContent.length}`,
@ -271,8 +277,14 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
// Save user message BEFORE sending final event to avoid race condition
// where client refetch happens before database is updated
const reqCtx = {
userId: req?.user?.id,
isTemporary: req?.body?.isTemporary,
interfaceConfig: req?.config?.interfaceConfig,
};
if (!client.skipSaveUserMessage && userMessage) {
await saveMessage(req, userMessage, {
await saveMessage(reqCtx, userMessage, {
context: 'api/server/controllers/agents/request.js - resumable user message',
});
}
@ -282,7 +294,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
// before the response is saved to the database, causing orphaned parentMessageIds.
if (client.savedMessageIds && !client.savedMessageIds.has(messageId)) {
await saveMessage(
req,
reqCtx,
{ ...response, user: userId, unfinished: wasAbortedBeforeComplete },
{ context: 'api/server/controllers/agents/request.js - resumable response end' },
);
@ -661,7 +673,11 @@ const _LegacyAgentController = async (req, res, next, initializeClient, addTitle
// Save the message if needed
if (client.savedMessageIds && !client.savedMessageIds.has(messageId)) {
await saveMessage(
req,
{
userId: req?.user?.id,
isTemporary: req?.body?.isTemporary,
interfaceConfig: req?.config?.interfaceConfig,
},
{ ...finalResponse, user: userId },
{ context: 'api/server/controllers/agents/request.js - response end' },
);
@ -690,9 +706,15 @@ const _LegacyAgentController = async (req, res, next, initializeClient, addTitle
// Save user message if needed
if (!client.skipSaveUserMessage) {
await saveMessage(req, userMessage, {
context: "api/server/controllers/agents/request.js - don't skip saving user message",
});
await saveMessage(
{
userId: req?.user?.id,
isTemporary: req?.body?.isTemporary,
interfaceConfig: req?.config?.interfaceConfig,
},
userMessage,
{ context: "api/server/controllers/agents/request.js - don't skip saving user message" },
);
}
// Add title if needed - extract minimal data

View file

@ -32,14 +32,13 @@ const {
} = require('@librechat/api');
const {
createResponsesToolEndCallback,
buildSummarizationHandlers,
markSummarizationUsage,
createToolEndCallback,
agentLogHandlerObj,
} = require('~/server/controllers/agents/callbacks');
const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService');
const { findAccessibleResources } = require('~/server/services/PermissionService');
const { getConvoFiles, saveConvo, getConvo } = require('~/models/Conversation');
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
const { getMultiplier, getCacheMultiplier } = require('~/models/tx');
const { getAgent, getAgents } = require('~/models/Agent');
const db = require('~/models');
/** @type {import('@librechat/api').AppConfig | null} */
@ -214,8 +213,12 @@ async function saveResponseOutput(req, conversationId, responseId, response, age
* @returns {Promise<void>}
*/
async function saveConversation(req, conversationId, agentId, agent) {
await saveConvo(
req,
await db.saveConvo(
{
userId: req?.user?.id,
isTemporary: req?.body?.isTemporary,
interfaceConfig: req?.config?.interfaceConfig,
},
{
conversationId,
endpoint: EModelEndpoint.agents,
@ -277,9 +280,10 @@ const createResponse = async (req, res) => {
const request = validation.request;
const agentId = request.model;
const isStreaming = request.stream === true;
const summarizationConfig = req.config?.summarization;
// Look up the agent
const agent = await getAgent({ id: agentId });
const agent = await db.getAgent({ id: agentId });
if (!agent) {
return sendResponsesErrorResponse(
res,
@ -319,7 +323,7 @@ const createResponse = async (req, res) => {
'invalid_request',
);
}
if (!(await getConvo(req.user?.id, request.previous_response_id))) {
if (!(await db.getConvo(req.user?.id, request.previous_response_id))) {
return sendResponsesErrorResponse(res, 404, 'Conversation not found', 'not_found');
}
}
@ -355,7 +359,7 @@ const createResponse = async (req, res) => {
isInitialAgent: true,
},
{
getConvoFiles,
getConvoFiles: db.getConvoFiles,
getFiles: db.getFiles,
getUserKey: db.getUserKey,
getMessages: db.getMessages,
@ -387,11 +391,11 @@ const createResponse = async (req, res) => {
const allMessages = [...previousMessages, ...inputMessages];
const toolSet = buildToolSet(primaryConfig);
const { messages: formattedMessages, indexTokenCountMap } = formatAgentMessages(
allMessages,
{},
toolSet,
);
const {
messages: formattedMessages,
indexTokenCountMap,
summary: initialSummary,
} = formatAgentMessages(allMessages, {}, toolSet);
// Create tracker for streaming or aggregator for non-streaming
const tracker = actuallyStreaming ? createResponseTracker() : null;
@ -455,11 +459,12 @@ const createResponse = async (req, res) => {
on_run_step: responsesHandlers.on_run_step,
on_run_step_delta: responsesHandlers.on_run_step_delta,
on_chat_model_end: {
handle: (event, data) => {
handle: (event, data, metadata) => {
responsesHandlers.on_chat_model_end.handle(event, data);
const usage = data?.output?.usage_metadata;
if (usage) {
collectedUsage.push(usage);
const taggedUsage = markSummarizationUsage(usage, metadata);
collectedUsage.push(taggedUsage);
}
},
},
@ -470,6 +475,10 @@ const createResponse = async (req, res) => {
on_agent_update: { handle: () => {} },
on_custom_event: { handle: () => {} },
on_tool_execute: createToolExecuteHandler(toolExecuteOptions),
on_agent_log: agentLogHandlerObj,
...(summarizationConfig?.enabled !== false
? buildSummarizationHandlers({ isStreaming: actuallyStreaming, res })
: {}),
};
// Create and run the agent
@ -480,7 +489,9 @@ const createResponse = async (req, res) => {
agents: [primaryConfig],
messages: formattedMessages,
indexTokenCountMap,
initialSummary,
runId: responseId,
summarizationConfig,
signal: abortController.signal,
customHandlers: handlers,
requestBody: {
@ -525,9 +536,9 @@ const createResponse = async (req, res) => {
const transactionsConfig = getTransactionsConfig(req.config);
recordCollectedUsage(
{
spendTokens,
spendStructuredTokens,
pricing: { getMultiplier, getCacheMultiplier },
spendTokens: db.spendTokens,
spendStructuredTokens: db.spendStructuredTokens,
pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
},
{
@ -612,11 +623,12 @@ const createResponse = async (req, res) => {
on_run_step: aggregatorHandlers.on_run_step,
on_run_step_delta: aggregatorHandlers.on_run_step_delta,
on_chat_model_end: {
handle: (event, data) => {
handle: (event, data, metadata) => {
aggregatorHandlers.on_chat_model_end.handle(event, data);
const usage = data?.output?.usage_metadata;
if (usage) {
collectedUsage.push(usage);
const taggedUsage = markSummarizationUsage(usage, metadata);
collectedUsage.push(taggedUsage);
}
},
},
@ -627,6 +639,10 @@ const createResponse = async (req, res) => {
on_agent_update: { handle: () => {} },
on_custom_event: { handle: () => {} },
on_tool_execute: createToolExecuteHandler(toolExecuteOptions),
on_agent_log: agentLogHandlerObj,
...(summarizationConfig?.enabled !== false
? buildSummarizationHandlers({ isStreaming: false, res })
: {}),
};
const userId = req.user?.id ?? 'api-user';
@ -636,7 +652,9 @@ const createResponse = async (req, res) => {
agents: [primaryConfig],
messages: formattedMessages,
indexTokenCountMap,
initialSummary,
runId: responseId,
summarizationConfig,
signal: abortController.signal,
customHandlers: handlers,
requestBody: {
@ -680,9 +698,9 @@ const createResponse = async (req, res) => {
const transactionsConfig = getTransactionsConfig(req.config);
recordCollectedUsage(
{
spendTokens,
spendStructuredTokens,
pricing: { getMultiplier, getCacheMultiplier },
spendTokens: db.spendTokens,
spendStructuredTokens: db.spendStructuredTokens,
pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
},
{
@ -782,7 +800,7 @@ const listModels = async (req, res) => {
// Get the accessible agents
let agents = [];
if (accessibleAgentIds.length > 0) {
agents = await getAgents({ _id: { $in: accessibleAgentIds } });
agents = await db.getAgents({ _id: { $in: accessibleAgentIds } });
}
// Convert to models format
@ -832,7 +850,7 @@ const getResponse = async (req, res) => {
// The responseId could be either the response ID or the conversation ID
// Try to find a conversation with this ID
const conversation = await getConvo(userId, responseId);
const conversation = await db.getConvo(userId, responseId);
if (!conversation) {
return sendResponsesErrorResponse(

View file

@ -3,6 +3,7 @@ const fs = require('fs').promises;
const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const {
refreshS3Url,
agentCreateSchema,
agentUpdateSchema,
refreshListAvatars,
@ -25,15 +26,6 @@ const {
actionDelimiter,
removeNullishValues,
} = require('librechat-data-provider');
const {
getListAgentsByAccess,
countPromotedAgents,
revertAgentVersion,
createAgent,
updateAgent,
deleteAgent,
getAgent,
} = require('~/models/Agent');
const {
findPubliclyAccessibleResources,
getResourcePermissionsMap,
@ -42,15 +34,14 @@ const {
grantPermission,
} = require('~/server/services/PermissionService');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { getCategoriesWithCounts, deleteFileByFilter } = require('~/models');
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { getFileStrategy } = require('~/server/utils/getFileStrategy');
const { refreshS3Url } = require('~/server/services/Files/S3/crud');
const { filterFile } = require('~/server/services/Files/process');
const { updateAction, getActions } = require('~/models/Action');
const { getCachedTools } = require('~/server/services/Config');
const { resolveConfigServers } = require('~/server/services/MCP');
const { getMCPServersRegistry } = require('~/config');
const { getLogStores } = require('~/cache');
const db = require('~/models');
const systemTools = {
[Tools.execute_code]: true,
@ -76,9 +67,7 @@ const validateEdgeAgentAccess = async (edges, userId, userRole) => {
return [];
}
const agents = (await Promise.all([...edgeAgentIds].map((id) => getAgent({ id })))).filter(
Boolean,
);
const agents = await db.getAgents({ id: { $in: [...edgeAgentIds] } });
if (agents.length === 0) {
return [];
@ -113,9 +102,16 @@ const validateEdgeAgentAccess = async (edges, userId, userRole) => {
* @param {string} params.userId - Requesting user ID for MCP server access check
* @param {Record<string, unknown>} params.availableTools - Global non-MCP tool cache
* @param {string[]} [params.existingTools] - Tools already persisted on the agent document
* @param {Record<string, unknown>} [params.configServers] - Config-source MCP servers resolved from appConfig overrides
* @returns {Promise<string[]>} Only the authorized subset of tools
*/
const filterAuthorizedTools = async ({ tools, userId, availableTools, existingTools }) => {
const filterAuthorizedTools = async ({
tools,
userId,
availableTools,
existingTools,
configServers,
}) => {
const filteredTools = [];
let mcpServerConfigs;
let registryUnavailable = false;
@ -133,7 +129,8 @@ const filterAuthorizedTools = async ({ tools, userId, availableTools, existingTo
if (mcpServerConfigs === undefined) {
try {
mcpServerConfigs = (await getMCPServersRegistry().getAllServerConfigs(userId)) ?? {};
mcpServerConfigs =
(await getMCPServersRegistry().getAllServerConfigs(userId, configServers)) ?? {};
} catch (e) {
logger.warn(
'[filterAuthorizedTools] MCP registry unavailable, filtering all MCP tools',
@ -204,10 +201,19 @@ const createAgentHandler = async (req, res) => {
agentData.author = userId;
agentData.tools = [];
const availableTools = (await getCachedTools()) ?? {};
agentData.tools = await filterAuthorizedTools({ tools, userId, availableTools });
const hasMCPTools = tools.some((t) => t?.includes(Constants.mcp_delimiter));
const [availableTools, configServers] = await Promise.all([
getCachedTools().then((t) => t ?? {}),
hasMCPTools ? resolveConfigServers(req) : Promise.resolve(undefined),
]);
agentData.tools = await filterAuthorizedTools({
tools,
userId,
availableTools,
configServers,
});
const agent = await createAgent(agentData);
const agent = await db.createAgent(agentData);
try {
await Promise.all([
@ -267,7 +273,7 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
// Permissions are validated by middleware before calling this function
// Simply load the agent by ID
const agent = await getAgent({ id });
const agent = await db.getAgent({ id });
if (!agent) {
return res.status(404).json({ error: 'Agent not found' });
@ -288,9 +294,6 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
agent.author = agent.author.toString();
// @deprecated - isCollaborative replaced by ACL permissions
agent.isCollaborative = !!agent.isCollaborative;
// Check if agent is public
const isPublic = await hasPublicPermission({
resourceType: ResourceType.AGENT,
@ -314,9 +317,6 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
author: agent.author,
provider: agent.provider,
model: agent.model,
projectIds: agent.projectIds,
// @deprecated - isCollaborative replaced by ACL permissions
isCollaborative: agent.isCollaborative,
isPublic: agent.isPublic,
version: agent.version,
// Safe metadata
@ -372,7 +372,7 @@ const updateAgentHandler = async (req, res) => {
// Convert OCR to context in incoming updateData
convertOcrToContextInPlace(updateData);
const existingAgent = await getAgent({ id });
const existingAgent = await db.getAgent({ id });
if (!existingAgent) {
return res.status(404).json({ error: 'Agent not found' });
@ -394,11 +394,15 @@ const updateAgentHandler = async (req, res) => {
);
if (newMCPTools.length > 0) {
const availableTools = (await getCachedTools()) ?? {};
const [availableTools, configServers] = await Promise.all([
getCachedTools().then((t) => t ?? {}),
resolveConfigServers(req),
]);
const approvedNew = await filterAuthorizedTools({
tools: newMCPTools,
userId: req.user.id,
availableTools,
configServers,
});
const rejectedSet = new Set(newMCPTools.filter((t) => !approvedNew.includes(t)));
if (rejectedSet.size > 0) {
@ -409,7 +413,7 @@ const updateAgentHandler = async (req, res) => {
let updatedAgent =
Object.keys(updateData).length > 0
? await updateAgent({ id }, updateData, {
? await db.updateAgent({ id }, updateData, {
updatingUserId: req.user.id,
})
: existingAgent;
@ -459,7 +463,7 @@ const duplicateAgentHandler = async (req, res) => {
const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
try {
const agent = await getAgent({ id });
const agent = await db.getAgent({ id });
if (!agent) {
return res.status(404).json({
error: 'Agent not found',
@ -507,7 +511,7 @@ const duplicateAgentHandler = async (req, res) => {
});
const newActionsList = [];
const originalActions = (await getActions({ agent_id: id }, true)) ?? [];
const originalActions = (await db.getActions({ agent_id: id }, true)) ?? [];
const promises = [];
/**
@ -526,7 +530,7 @@ const duplicateAgentHandler = async (req, res) => {
delete filteredMetadata[field];
}
const newAction = await updateAction(
const newAction = await db.updateAction(
{ action_id: newActionId, agent_id: newAgentId },
{
metadata: filteredMetadata,
@ -551,16 +555,20 @@ const duplicateAgentHandler = async (req, res) => {
newAgentData.actions = agentActions;
if (newAgentData.tools?.length) {
const availableTools = (await getCachedTools()) ?? {};
const [availableTools, configServers] = await Promise.all([
getCachedTools().then((t) => t ?? {}),
resolveConfigServers(req),
]);
newAgentData.tools = await filterAuthorizedTools({
tools: newAgentData.tools,
userId,
availableTools,
existingTools: newAgentData.tools,
configServers,
});
}
const newAgent = await createAgent(newAgentData);
const newAgent = await db.createAgent(newAgentData);
try {
await Promise.all([
@ -613,11 +621,11 @@ const duplicateAgentHandler = async (req, res) => {
const deleteAgentHandler = async (req, res) => {
try {
const id = req.params.id;
const agent = await getAgent({ id });
const agent = await db.getAgent({ id });
if (!agent) {
return res.status(404).json({ error: 'Agent not found' });
}
await deleteAgent({ id });
await db.deleteAgent({ id });
return res.json({ message: 'Agent deleted' });
} catch (error) {
logger.error('[/Agents/:id] Error deleting Agent', error);
@ -692,7 +700,7 @@ const getListAgentsHandler = async (req, res) => {
cachedRefresh != null && typeof cachedRefresh === 'object' && cachedRefresh.urlCache != null;
if (!isValidCachedRefresh) {
try {
const fullList = await getListAgentsByAccess({
const fullList = await db.getListAgentsByAccess({
accessibleIds,
otherParams: {},
limit: MAX_AVATAR_REFRESH_AGENTS,
@ -702,7 +710,7 @@ const getListAgentsHandler = async (req, res) => {
agents: fullList?.data ?? [],
userId,
refreshS3Url,
updateAgent,
updateAgent: db.updateAgent,
});
cachedRefresh = { urlCache };
await cache.set(refreshKey, cachedRefresh, Time.THIRTY_MINUTES);
@ -714,7 +722,7 @@ const getListAgentsHandler = async (req, res) => {
}
// Use the new ACL-aware function
const data = await getListAgentsByAccess({
const data = await db.getListAgentsByAccess({
accessibleIds,
otherParams: filter,
limit,
@ -779,7 +787,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
return res.status(400).json({ message: 'Agent ID is required' });
}
const existingAgent = await getAgent({ id: agent_id });
const existingAgent = await db.getAgent({ id: agent_id });
if (!existingAgent) {
return res.status(404).json({ error: 'Agent not found' });
@ -811,7 +819,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
const { deleteFile } = getStrategyFunctions(_avatar.source);
try {
await deleteFile(req, { filepath: _avatar.filepath });
await deleteFileByFilter({ user: req.user.id, filepath: _avatar.filepath });
await db.deleteFileByFilter({ user: req.user.id, filepath: _avatar.filepath });
} catch (error) {
logger.error('[/:agent_id/avatar] Error deleting old avatar', error);
}
@ -824,7 +832,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
},
};
const updatedAgent = await updateAgent({ id: agent_id }, data, {
const updatedAgent = await db.updateAgent({ id: agent_id }, data, {
updatingUserId: req.user.id,
});
@ -880,7 +888,7 @@ const revertAgentVersionHandler = async (req, res) => {
return res.status(400).json({ error: 'version_index is required' });
}
const existingAgent = await getAgent({ id });
const existingAgent = await db.getAgent({ id });
if (!existingAgent) {
return res.status(404).json({ error: 'Agent not found' });
@ -888,18 +896,22 @@ const revertAgentVersionHandler = async (req, res) => {
// Permissions are enforced via route middleware (ACL EDIT)
let updatedAgent = await revertAgentVersion({ id }, version_index);
let updatedAgent = await db.revertAgentVersion({ id }, version_index);
if (updatedAgent.tools?.length) {
const availableTools = (await getCachedTools()) ?? {};
const [availableTools, configServers] = await Promise.all([
getCachedTools().then((t) => t ?? {}),
resolveConfigServers(req),
]);
const filteredTools = await filterAuthorizedTools({
tools: updatedAgent.tools,
userId: req.user.id,
availableTools,
existingTools: updatedAgent.tools,
configServers,
});
if (filteredTools.length !== updatedAgent.tools.length) {
updatedAgent = await updateAgent(
updatedAgent = await db.updateAgent(
{ id },
{ tools: filteredTools },
{ updatingUserId: req.user.id },
@ -929,8 +941,8 @@ const revertAgentVersionHandler = async (req, res) => {
*/
const getAgentCategories = async (_req, res) => {
try {
const categories = await getCategoriesWithCounts();
const promotedCount = await countPromotedAgents();
const categories = await db.getCategoriesWithCounts();
const promotedCount = await db.countPromotedAgents();
const formattedCategories = categories.map((category) => ({
value: category.value,
label: category.label,

View file

@ -14,10 +14,6 @@ jest.mock('~/server/services/Config', () => ({
}),
}));
jest.mock('~/models/Project', () => ({
getProjectByName: jest.fn().mockResolvedValue(null),
}));
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(),
}));
@ -26,7 +22,16 @@ jest.mock('~/server/services/Files/images/avatar', () => ({
resizeAvatar: jest.fn(),
}));
jest.mock('~/server/services/Files/S3/crud', () => ({
jest.mock('sharp', () =>
jest.fn(() => ({
metadata: jest.fn().mockResolvedValue({}),
toFormat: jest.fn().mockReturnThis(),
toBuffer: jest.fn().mockResolvedValue(Buffer.alloc(0)),
})),
);
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
refreshS3Url: jest.fn(),
}));
@ -34,15 +39,6 @@ jest.mock('~/server/services/Files/process', () => ({
filterFile: jest.fn(),
}));
jest.mock('~/models/Action', () => ({
updateAction: jest.fn(),
getActions: jest.fn().mockResolvedValue([]),
}));
jest.mock('~/models/File', () => ({
deleteFileByFilter: jest.fn(),
}));
jest.mock('~/server/services/PermissionService', () => ({
findAccessibleResources: jest.fn().mockResolvedValue([]),
findPubliclyAccessibleResources: jest.fn().mockResolvedValue([]),
@ -51,9 +47,18 @@ jest.mock('~/server/services/PermissionService', () => ({
hasPublicPermission: jest.fn().mockResolvedValue(false),
}));
jest.mock('~/models', () => ({
getCategoriesWithCounts: jest.fn(),
}));
jest.mock('~/models', () => {
const mongoose = require('mongoose');
const { createMethods } = require('@librechat/data-schemas');
const methods = createMethods(mongoose, {
removeAllPermissions: jest.fn().mockResolvedValue(undefined),
});
return {
...methods,
getCategoriesWithCounts: jest.fn(),
deleteFileByFilter: jest.fn(),
};
});
// Mock cache for S3 avatar refresh tests
const mockCache = {
@ -77,7 +82,7 @@ const {
getResourcePermissionsMap,
} = require('~/server/services/PermissionService');
const { refreshS3Url } = require('~/server/services/Files/S3/crud');
const { refreshS3Url } = require('@librechat/api');
/**
* @type {import('mongoose').Model<import('@librechat/data-schemas').IAgent>}
@ -176,7 +181,6 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
// Unauthorized fields that should be stripped
author: new mongoose.Types.ObjectId().toString(), // Should not be able to set author
authorName: 'Hacker', // Should be stripped
isCollaborative: true, // Should be stripped on creation
versions: [], // Should be stripped
_id: new mongoose.Types.ObjectId(), // Should be stripped
id: 'custom_agent_id', // Should be overridden
@ -195,7 +199,6 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
// Verify unauthorized fields were not set
expect(createdAgent.author.toString()).toBe(mockReq.user.id); // Should be the request user, not the malicious value
expect(createdAgent.authorName).toBeUndefined();
expect(createdAgent.isCollaborative).toBeFalsy();
expect(createdAgent.versions).toHaveLength(1); // Should have exactly 1 version from creation
expect(createdAgent.id).not.toBe('custom_agent_id'); // Should have generated ID
expect(createdAgent.id).toMatch(/^agent_/); // Should have proper prefix
@ -446,7 +449,6 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
model: 'gpt-3.5-turbo',
author: existingAgentAuthorId,
description: 'Original description',
isCollaborative: false,
versions: [
{
name: 'Original Agent',
@ -468,7 +470,6 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
name: 'Updated Agent',
description: 'Updated description',
model: 'gpt-4',
isCollaborative: true, // This IS allowed in updates
};
await updateAgentHandler(mockReq, mockRes);
@ -481,13 +482,11 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(updatedAgent.name).toBe('Updated Agent');
expect(updatedAgent.description).toBe('Updated description');
expect(updatedAgent.model).toBe('gpt-4');
expect(updatedAgent.isCollaborative).toBe(true);
expect(updatedAgent.author).toBe(existingAgentAuthorId.toString());
// Verify in database
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.name).toBe('Updated Agent');
expect(agentInDb.isCollaborative).toBe(true);
});
test('should reject update with unauthorized fields (mass assignment protection)', async () => {
@ -542,26 +541,6 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(updatedAgent.name).toBe('Admin Update');
});
test('should handle projectIds updates', async () => {
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
const projectId1 = new mongoose.Types.ObjectId().toString();
const projectId2 = new mongoose.Types.ObjectId().toString();
mockReq.body = {
projectIds: [projectId1, projectId2],
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent).toBeDefined();
// Note: updateAgentProjects requires more setup, so we just verify the handler doesn't crash
});
test('should validate tool_resources in updates', async () => {
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;

View file

@ -1,7 +1,13 @@
const { v4 } = require('uuid');
const { sleep } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
const { sendEvent, getBalanceConfig, getModelMaxTokens, countTokens } = require('@librechat/api');
const {
sendEvent,
countTokens,
checkBalance,
getBalanceConfig,
getModelMaxTokens,
} = require('@librechat/api');
const {
Time,
Constants,
@ -31,10 +37,15 @@ const { createRun, StreamRunManager } = require('~/server/services/Runs');
const { addTitle } = require('~/server/services/Endpoints/assistants');
const { createRunBody } = require('~/server/services/createRunBody');
const { sendResponse } = require('~/server/middleware/error');
const { getTransactions } = require('~/models/Transaction');
const { checkBalance } = require('~/models/balanceMethods');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const {
createAutoRefillTransaction,
findBalanceByUser,
upsertBalanceFields,
getTransactions,
getMultiplier,
getConvo,
} = require('~/models');
const { logViolation, getLogStores } = require('~/cache');
const { getOpenAIClient } = require('./helpers');
/**
@ -275,16 +286,26 @@ const chatV1 = async (req, res) => {
// Count tokens up to the current context window
promptTokens = Math.min(promptTokens, getModelMaxTokens(model));
await checkBalance({
req,
res,
txData: {
model,
user: req.user.id,
tokenType: 'prompt',
amount: promptTokens,
await checkBalance(
{
req,
res,
txData: {
model,
user: req.user.id,
tokenType: 'prompt',
amount: promptTokens,
},
},
});
{
findBalanceByUser,
getMultiplier,
createAutoRefillTransaction,
logViolation,
balanceConfig,
upsertBalanceFields,
},
);
};
const { openai: _openai } = await getOpenAIClient({

View file

@ -1,7 +1,13 @@
const { v4 } = require('uuid');
const { sleep } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
const { sendEvent, getBalanceConfig, getModelMaxTokens, countTokens } = require('@librechat/api');
const {
sendEvent,
countTokens,
checkBalance,
getBalanceConfig,
getModelMaxTokens,
} = require('@librechat/api');
const {
Time,
Constants,
@ -26,10 +32,15 @@ const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { createRun, StreamRunManager } = require('~/server/services/Runs');
const { addTitle } = require('~/server/services/Endpoints/assistants');
const { createRunBody } = require('~/server/services/createRunBody');
const { getTransactions } = require('~/models/Transaction');
const { checkBalance } = require('~/models/balanceMethods');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const {
getConvo,
getMultiplier,
getTransactions,
findBalanceByUser,
upsertBalanceFields,
createAutoRefillTransaction,
} = require('~/models');
const { logViolation, getLogStores } = require('~/cache');
const { getOpenAIClient } = require('./helpers');
/**
@ -148,16 +159,26 @@ const chatV2 = async (req, res) => {
// Count tokens up to the current context window
promptTokens = Math.min(promptTokens, getModelMaxTokens(model));
await checkBalance({
req,
res,
txData: {
model,
user: req.user.id,
tokenType: 'prompt',
amount: promptTokens,
await checkBalance(
{
req,
res,
txData: {
model,
user: req.user.id,
tokenType: 'prompt',
amount: promptTokens,
},
},
});
{
findBalanceByUser,
getMultiplier,
createAutoRefillTransaction,
logViolation,
balanceConfig,
upsertBalanceFields,
},
);
};
const { openai: _openai } = await getOpenAIClient({

View file

@ -3,8 +3,8 @@ const { logger } = require('@librechat/data-schemas');
const { CacheKeys, ViolationTypes, ContentTypes } = require('librechat-data-provider');
const { recordUsage, checkMessageGaps } = require('~/server/services/Threads');
const { sendResponse } = require('~/server/middleware/error');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const { getConvo } = require('~/models');
/**
* @typedef {Object} ErrorHandlerContext

View file

@ -1,13 +1,14 @@
const {
SystemRoles,
EModelEndpoint,
defaultOrderQuery,
defaultAssistantsVersion,
} = require('librechat-data-provider');
const { logger, SystemCapabilities } = require('@librechat/data-schemas');
const {
initializeClient: initAzureClient,
} = require('~/server/services/Endpoints/azureAssistants');
const { initializeClient } = require('~/server/services/Endpoints/assistants');
const { hasCapability } = require('~/server/middleware/roles/capabilities');
const { getEndpointsConfig } = require('~/server/services/Config');
/**
@ -236,9 +237,19 @@ const fetchAssistants = async ({ req, res, overrideEndpoint }) => {
body = await listAssistantsForAzure({ req, res, version, azureConfig, query });
}
if (req.user.role === SystemRoles.ADMIN) {
if (!appConfig.endpoints?.[endpoint]) {
return body;
} else if (!appConfig.endpoints?.[endpoint]) {
}
let canManageAssistants = false;
try {
canManageAssistants = await hasCapability(req.user, SystemCapabilities.MANAGE_ASSISTANTS);
} catch (err) {
logger.warn(`[fetchAssistants] capability check failed, denying bypass: ${err.message}`);
}
if (canManageAssistants) {
logger.debug(`[fetchAssistants] MANAGE_ASSISTANTS bypass for user ${req.user.id}`);
return body;
}

View file

@ -1,15 +1,14 @@
const fs = require('fs').promises;
const { logger } = require('@librechat/data-schemas');
const { FileContext } = require('librechat-data-provider');
const { deleteFileByFilter, updateAssistantDoc, getAssistants } = require('~/models');
const { uploadImageBuffer, filterFile } = require('~/server/services/Files/process');
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { deleteAssistantActions } = require('~/server/services/ActionService');
const { updateAssistantDoc, getAssistants } = require('~/models/Assistant');
const { getOpenAIClient, fetchAssistants } = require('./helpers');
const { getCachedTools } = require('~/server/services/Config');
const { manifestToolMap } = require('~/app/clients/tools');
const { deleteFileByFilter } = require('~/models');
/**
* Create an assistant.

View file

@ -3,8 +3,8 @@ const { ToolCallTypes } = require('librechat-data-provider');
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { validateAndUpdateTool } = require('~/server/services/ActionService');
const { getCachedTools } = require('~/server/services/Config');
const { updateAssistantDoc } = require('~/models/Assistant');
const { manifestToolMap } = require('~/app/clients/tools');
const { updateAssistantDoc } = require('~/models');
const { getOpenAIClient } = require('./helpers');
/**

View file

@ -4,11 +4,27 @@ const { logger } = require('@librechat/data-schemas');
const { logoutUser } = require('~/server/services/AuthService');
const { getOpenIdConfig } = require('~/strategies');
/** Parses and validates OPENID_MAX_LOGOUT_URL_LENGTH, returning defaultValue on invalid input */
function parseMaxLogoutUrlLength(defaultValue = 2000) {
const raw = process.env.OPENID_MAX_LOGOUT_URL_LENGTH;
const trimmed = raw == null ? '' : raw.trim();
if (trimmed === '') {
return defaultValue;
}
const parsed = /^\d+$/.test(trimmed) ? Number(trimmed) : NaN;
if (!Number.isFinite(parsed) || parsed <= 0) {
logger.warn(
`[logoutController] Invalid OPENID_MAX_LOGOUT_URL_LENGTH value "${raw}", using default ${defaultValue}`,
);
return defaultValue;
}
return parsed;
}
const logoutController = async (req, res) => {
const parsedCookies = req.headers.cookie ? cookies.parse(req.headers.cookie) : {};
const isOpenIdUser = req.user?.openidId != null && req.user?.provider === 'openid';
/** For OpenID users, read tokens from session (with cookie fallback) */
let refreshToken;
let idToken;
if (isOpenIdUser && req.session?.openidTokens) {
@ -44,22 +60,64 @@ const logoutController = async (req, res) => {
const endSessionEndpoint = openIdConfig.serverMetadata().end_session_endpoint;
if (endSessionEndpoint) {
const endSessionUrl = new URL(endSessionEndpoint);
/** Redirect back to app's login page after IdP logout */
const postLogoutRedirectUri =
process.env.OPENID_POST_LOGOUT_REDIRECT_URI || `${process.env.DOMAIN_CLIENT}/login`;
endSessionUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
/** Add id_token_hint (preferred) or client_id for OIDC spec compliance */
/**
* OIDC RP-Initiated Logout cascading strategy:
* 1. id_token_hint (most secure, identifies exact session)
* 2. logout_hint + client_id (when URL would exceed safe length)
* 3. client_id only (when no token available)
*
* JWT tokens from spec-compliant OIDC providers use base64url
* encoding (RFC 7515), whose characters are all URL-safe, so
* token length equals URL-encoded length for projection.
* Non-compliant issuers using standard base64 (+/=) will cause
* underestimation; increase OPENID_MAX_LOGOUT_URL_LENGTH if the
* fallback does not trigger as expected.
*/
const maxLogoutUrlLength = parseMaxLogoutUrlLength();
let strategy = 'no_token';
if (idToken) {
const baseLength = endSessionUrl.toString().length;
const projectedLength = baseLength + '&id_token_hint='.length + idToken.length;
if (projectedLength > maxLogoutUrlLength) {
strategy = 'too_long';
logger.debug(
`[logoutController] Logout URL too long (${projectedLength} chars, max ${maxLogoutUrlLength}), ` +
'switching to logout_hint strategy',
);
} else {
strategy = 'use_token';
}
}
if (strategy === 'use_token') {
endSessionUrl.searchParams.set('id_token_hint', idToken);
} else if (process.env.OPENID_CLIENT_ID) {
endSessionUrl.searchParams.set('client_id', process.env.OPENID_CLIENT_ID);
} else {
logger.warn(
'[logoutController] Neither id_token_hint nor OPENID_CLIENT_ID is available. ' +
'To enable id_token_hint, set OPENID_REUSE_TOKENS=true. ' +
'The OIDC end-session request may be rejected by the identity provider.',
);
if (strategy === 'too_long') {
const logoutHint = req.user?.email || req.user?.username || req.user?.openidId;
if (logoutHint) {
endSessionUrl.searchParams.set('logout_hint', logoutHint);
}
}
if (process.env.OPENID_CLIENT_ID) {
endSessionUrl.searchParams.set('client_id', process.env.OPENID_CLIENT_ID);
} else if (strategy === 'too_long') {
logger.warn(
'[logoutController] Logout URL exceeds max length and OPENID_CLIENT_ID is not set. ' +
'The OIDC end-session request may be rejected. ' +
'Consider setting OPENID_CLIENT_ID or increasing OPENID_MAX_LOGOUT_URL_LENGTH.',
);
} else {
logger.warn(
'[logoutController] Neither id_token_hint nor OPENID_CLIENT_ID is available. ' +
'To enable id_token_hint, set OPENID_REUSE_TOKENS=true. ' +
'The OIDC end-session request may be rejected by the identity provider.',
);
}
}
response.redirect = endSessionUrl.toString();

View file

@ -1,7 +1,7 @@
const cookies = require('cookie');
const mockLogoutUser = jest.fn();
const mockLogger = { warn: jest.fn(), error: jest.fn() };
const mockLogger = { warn: jest.fn(), error: jest.fn(), debug: jest.fn() };
const mockIsEnabled = jest.fn();
const mockGetOpenIdConfig = jest.fn();
@ -256,4 +256,312 @@ describe('LogoutController', () => {
expect(res.clearCookie).toHaveBeenCalledWith('token_provider');
});
});
describe('URL length limit and logout_hint fallback', () => {
it('uses logout_hint when id_token makes URL exceed default limit (2000 chars)', async () => {
const longIdToken = 'a'.repeat(3000);
const req = buildReq({
user: { _id: 'user1', openidId: 'oid1', provider: 'openid', email: 'user@example.com' },
session: {
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('id_token_hint=');
expect(body.redirect).toContain('logout_hint=user%40example.com');
expect(body.redirect).toContain('client_id=my-client-id');
expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('Logout URL too long'));
});
it('uses id_token_hint when URL is within default limit', async () => {
const shortIdToken = 'short-token';
const req = buildReq({
session: {
openidTokens: { refreshToken: 'srt', idToken: shortIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=short-token');
expect(body.redirect).not.toContain('logout_hint=');
expect(body.redirect).not.toContain('client_id=');
});
it('respects custom OPENID_MAX_LOGOUT_URL_LENGTH', async () => {
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = '500';
const mediumIdToken = 'a'.repeat(600);
const req = buildReq({
user: { _id: 'user1', openidId: 'oid1', provider: 'openid', email: 'user@example.com' },
session: {
openidTokens: { refreshToken: 'srt', idToken: mediumIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('id_token_hint=');
expect(body.redirect).toContain('logout_hint=user%40example.com');
});
it('uses username as logout_hint when email is not available', async () => {
const longIdToken = 'a'.repeat(3000);
const req = buildReq({
user: {
_id: 'user1',
openidId: 'oid1',
provider: 'openid',
username: 'testuser',
},
session: {
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('logout_hint=testuser');
});
it('uses openidId as logout_hint when email and username are not available', async () => {
const longIdToken = 'a'.repeat(3000);
const req = buildReq({
user: { _id: 'user1', openidId: 'unique-oid-123', provider: 'openid' },
session: {
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('logout_hint=unique-oid-123');
});
it('uses openidId as logout_hint when email and username are explicitly null', async () => {
const longIdToken = 'a'.repeat(3000);
const req = buildReq({
user: {
_id: 'user1',
openidId: 'oid-without-email',
provider: 'openid',
email: null,
username: null,
},
session: {
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('id_token_hint=');
expect(body.redirect).toContain('logout_hint=oid-without-email');
expect(body.redirect).toContain('client_id=my-client-id');
});
it('uses only client_id when absolutely no hint is available', async () => {
const longIdToken = 'a'.repeat(3000);
const req = buildReq({
user: {
_id: 'user1',
openidId: '',
provider: 'openid',
email: '',
username: '',
},
session: {
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('id_token_hint=');
expect(body.redirect).not.toContain('logout_hint=');
expect(body.redirect).toContain('client_id=my-client-id');
});
it('warns about missing OPENID_CLIENT_ID when URL is too long', async () => {
delete process.env.OPENID_CLIENT_ID;
const longIdToken = 'a'.repeat(3000);
const req = buildReq({
user: { _id: 'user1', openidId: 'oid1', provider: 'openid', email: 'user@example.com' },
session: {
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('id_token_hint=');
expect(body.redirect).toContain('logout_hint=');
expect(body.redirect).not.toContain('client_id=');
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('OPENID_CLIENT_ID is not set'),
);
});
it('falls back to logout_hint for cookie-sourced long token', async () => {
const longCookieToken = 'a'.repeat(3000);
cookies.parse.mockReturnValue({
refreshToken: 'cookie-rt',
openid_id_token: longCookieToken,
});
const req = buildReq({
user: { _id: 'user1', openidId: 'oid1', provider: 'openid', email: 'user@example.com' },
session: { destroy: jest.fn() },
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('id_token_hint=');
expect(body.redirect).toContain('logout_hint=user%40example.com');
expect(body.redirect).toContain('client_id=my-client-id');
});
it('keeps id_token_hint when projected URL length equals the max', async () => {
const baseUrl = new URL('https://idp.example.com/logout');
baseUrl.searchParams.set('post_logout_redirect_uri', 'https://app.example.com/login');
const baseLength = baseUrl.toString().length;
const tokenLength = 2000 - baseLength - '&id_token_hint='.length;
const exactToken = 'a'.repeat(tokenLength);
const req = buildReq({
session: {
openidTokens: { refreshToken: 'srt', idToken: exactToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=');
expect(body.redirect).not.toContain('logout_hint=');
});
it('falls back to logout_hint when projected URL is one char over the max', async () => {
const baseUrl = new URL('https://idp.example.com/logout');
baseUrl.searchParams.set('post_logout_redirect_uri', 'https://app.example.com/login');
const baseLength = baseUrl.toString().length;
const tokenLength = 2000 - baseLength - '&id_token_hint='.length + 1;
const overToken = 'a'.repeat(tokenLength);
const req = buildReq({
user: { _id: 'user1', openidId: 'oid1', provider: 'openid', email: 'user@example.com' },
session: {
openidTokens: { refreshToken: 'srt', idToken: overToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('id_token_hint=');
expect(body.redirect).toContain('logout_hint=');
});
});
describe('invalid OPENID_MAX_LOGOUT_URL_LENGTH values', () => {
it('silently uses default when value is empty', async () => {
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = '';
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
expect(mockLogger.warn).not.toHaveBeenCalledWith(
expect.stringContaining('Invalid OPENID_MAX_LOGOUT_URL_LENGTH'),
);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=small-id-token');
});
it('warns and uses default for partial numeric string', async () => {
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = '500abc';
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Invalid OPENID_MAX_LOGOUT_URL_LENGTH'),
);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=small-id-token');
});
it('warns and uses default for zero value', async () => {
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = '0';
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Invalid OPENID_MAX_LOGOUT_URL_LENGTH'),
);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=small-id-token');
});
it('warns and uses default for negative value', async () => {
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = '-1';
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Invalid OPENID_MAX_LOGOUT_URL_LENGTH'),
);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=small-id-token');
});
it('warns and uses default for non-numeric string', async () => {
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = 'abc';
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Invalid OPENID_MAX_LOGOUT_URL_LENGTH'),
);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=small-id-token');
});
});
});

View file

@ -47,9 +47,15 @@ function createOAuthHandler(redirectUri = domains.client) {
const refreshToken =
req.user.tokenset?.refresh_token || req.user.federatedTokens?.refresh_token;
const exchangeCode = await generateAdminExchangeCode(cache, req.user, token, refreshToken);
const callbackUrl = new URL(redirectUri);
const exchangeCode = await generateAdminExchangeCode(
cache,
req.user,
token,
refreshToken,
callbackUrl.origin,
req.pkceChallenge,
);
callbackUrl.searchParams.set('code', exchangeCode);
logger.info(`[OAuth] Admin panel redirect with exchange code for user: ${req.user.email}`);
return res.redirect(callbackUrl.toString());

View file

@ -14,6 +14,7 @@ const {
isMCPInspectionFailedError,
} = require('@librechat/api');
const { Constants, MCPServerUserInputSchema } = require('librechat-data-provider');
const { resolveConfigServers, resolveAllMcpConfigs } = require('~/server/services/MCP');
const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config');
const { getMCPManager, getMCPServersRegistry } = require('~/config');
@ -57,7 +58,7 @@ function handleMCPError(error, res) {
}
/**
* Get all MCP tools available to the user
* Get all MCP tools available to the user.
*/
const getMCPTools = async (req, res) => {
try {
@ -67,10 +68,10 @@ const getMCPTools = async (req, res) => {
return res.status(401).json({ message: 'Unauthorized' });
}
const mcpConfig = await getMCPServersRegistry().getAllServerConfigs(userId);
const configuredServers = mcpConfig ? Object.keys(mcpConfig) : [];
const mcpConfig = await resolveAllMcpConfigs(userId, req.user);
const configuredServers = Object.keys(mcpConfig);
if (!mcpConfig || Object.keys(mcpConfig).length == 0) {
if (!configuredServers.length) {
return res.status(200).json({ servers: {} });
}
@ -115,14 +116,11 @@ const getMCPTools = async (req, res) => {
try {
const serverTools = serverToolsMap.get(serverName);
// Get server config once
const serverConfig = mcpConfig[serverName];
const rawServerConfig = await getMCPServersRegistry().getServerConfig(serverName, userId);
// Initialize server object with all server-level data
const server = {
name: serverName,
icon: rawServerConfig?.iconPath || '',
icon: serverConfig?.iconPath || '',
authenticated: true,
authConfig: [],
tools: [],
@ -183,7 +181,7 @@ const getMCPServersList = async (req, res) => {
return res.status(401).json({ message: 'Unauthorized' });
}
const serverConfigs = await getMCPServersRegistry().getAllServerConfigs(userId);
const serverConfigs = await resolveAllMcpConfigs(userId, req.user);
return res.json(redactAllServerSecrets(serverConfigs));
} catch (error) {
logger.error('[getMCPServersList]', error);
@ -237,7 +235,12 @@ const getMCPServerById = async (req, res) => {
if (!serverName) {
return res.status(400).json({ message: 'Server name is required' });
}
const parsedConfig = await getMCPServersRegistry().getServerConfig(serverName, userId);
const configServers = await resolveConfigServers(req);
const parsedConfig = await getMCPServersRegistry().getServerConfig(
serverName,
userId,
configServers,
);
if (!parsedConfig) {
return res.status(404).json({ message: 'MCP server not found' });

View file

@ -9,13 +9,11 @@ const {
ToolCallTypes,
PermissionTypes,
} = require('librechat-data-provider');
const { getRoleByName, createToolCall, getToolCallsByConvo, getMessage } = require('~/models');
const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process');
const { processCodeOutput } = require('~/server/services/Files/Code/process');
const { createToolCall, getToolCallsByConvo } = require('~/models/ToolCall');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { loadTools } = require('~/app/clients/tools/util');
const { getRoleByName } = require('~/models/Role');
const { getMessage } = require('~/models/Message');
const fieldsMap = {
[Tools.execute_code]: [EnvVar.CODE_API_KEY],

View file

@ -19,19 +19,21 @@ const {
performStartupChecks,
handleJsonParseError,
initializeFileStorage,
preAuthTenantMiddleware,
} = require('@librechat/api');
const { connectDb, indexSync } = require('~/db');
const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
const createValidateImageRequest = require('./middleware/validateImageRequest');
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
const { updateInterfacePermissions } = require('~/models/interface');
const { updateInterfacePermissions: updateInterfacePerms } = require('@librechat/api');
const { getRoleByName, updateAccessPermissions, seedDatabase } = require('~/models');
const { checkMigrations } = require('./services/start/migration');
const initializeMCPs = require('./services/initializeMCPs');
const configureSocialLogins = require('./socialLogins');
const { getAppConfig } = require('./services/Config');
const staticCache = require('./utils/staticCache');
const optionalJwtAuth = require('./middleware/optionalJwtAuth');
const noIndex = require('./middleware/noIndex');
const { seedDatabase } = require('~/models');
const routes = require('./routes');
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
@ -222,7 +224,7 @@ if (cluster.isMaster) {
const appConfig = await getAppConfig();
initializeFileStorage(appConfig);
await performStartupChecks(appConfig);
await updateInterfacePermissions(appConfig);
await updateInterfacePerms({ appConfig, getRoleByName, updateAccessPermissions });
/** Load index.html for SPA serving */
const indexPath = path.join(appConfig.paths.dist, 'index.html');
@ -312,7 +314,7 @@ if (cluster.isMaster) {
app.use('/api/endpoints', routes.endpoints);
app.use('/api/balance', routes.balance);
app.use('/api/models', routes.models);
app.use('/api/config', routes.config);
app.use('/api/config', preAuthTenantMiddleware, optionalJwtAuth, routes.config);
app.use('/api/assistants', routes.assistants);
app.use('/api/files', await routes.files.initialize());
app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute);

View file

@ -8,8 +8,8 @@ const express = require('express');
const passport = require('passport');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const { logger } = require('@librechat/data-schemas');
const mongoSanitize = require('express-mongo-sanitize');
const { logger, runAsSystem } = require('@librechat/data-schemas');
const {
isEnabled,
apiNotFound,
@ -20,19 +20,22 @@ const {
GenerationJobManager,
createStreamServices,
initializeFileStorage,
updateInterfacePermissions,
preAuthTenantMiddleware,
} = require('@librechat/api');
const { connectDb, indexSync } = require('~/db');
const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
const { getRoleByName, updateAccessPermissions, seedDatabase } = require('~/models');
const { capabilityContextMiddleware } = require('./middleware/roles/capabilities');
const createValidateImageRequest = require('./middleware/validateImageRequest');
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
const { updateInterfacePermissions } = require('~/models/interface');
const { checkMigrations } = require('./services/start/migration');
const initializeMCPs = require('./services/initializeMCPs');
const configureSocialLogins = require('./socialLogins');
const { getAppConfig } = require('./services/Config');
const staticCache = require('./utils/staticCache');
const optionalJwtAuth = require('./middleware/optionalJwtAuth');
const noIndex = require('./middleware/noIndex');
const { seedDatabase } = require('~/models');
const routes = require('./routes');
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
@ -58,11 +61,20 @@ const startServer = async () => {
app.disable('x-powered-by');
app.set('trust proxy', trusted_proxy);
await seedDatabase();
const appConfig = await getAppConfig();
if (isEnabled(process.env.TENANT_ISOLATION_STRICT)) {
logger.warn(
'[Security] TENANT_ISOLATION_STRICT is active. Ensure your reverse proxy strips or sets ' +
'the X-Tenant-Id header — untrusted clients must not be able to set it directly.',
);
}
await runAsSystem(seedDatabase);
const appConfig = await getAppConfig({ baseOnly: true });
initializeFileStorage(appConfig);
await performStartupChecks(appConfig);
await updateInterfacePermissions(appConfig);
await runAsSystem(async () => {
await performStartupChecks(appConfig);
await updateInterfacePermissions({ appConfig, getRoleByName, updateAccessPermissions });
});
const indexPath = path.join(appConfig.paths.dist, 'index.html');
let indexHTML = fs.readFileSync(indexPath, 'utf8');
@ -133,10 +145,20 @@ const startServer = async () => {
await configureSocialLogins(app);
}
app.use('/oauth', routes.oauth);
/* Per-request capability cache — must be registered before any route that calls hasCapability */
app.use(capabilityContextMiddleware);
/* Pre-auth tenant context for unauthenticated routes that need tenant scoping.
* The reverse proxy / auth gateway sets `X-Tenant-Id` header for multi-tenant deployments. */
app.use('/oauth', preAuthTenantMiddleware, routes.oauth);
/* API Endpoints */
app.use('/api/auth', routes.auth);
app.use('/api/auth', preAuthTenantMiddleware, routes.auth);
app.use('/api/admin', routes.adminAuth);
app.use('/api/admin/config', routes.adminConfig);
app.use('/api/admin/grants', routes.adminGrants);
app.use('/api/admin/groups', routes.adminGroups);
app.use('/api/admin/roles', routes.adminRoles);
app.use('/api/admin/users', routes.adminUsers);
app.use('/api/actions', routes.actions);
app.use('/api/keys', routes.keys);
app.use('/api/api-keys', routes.apiKeys);
@ -150,11 +172,11 @@ const startServer = async () => {
app.use('/api/endpoints', routes.endpoints);
app.use('/api/balance', routes.balance);
app.use('/api/models', routes.models);
app.use('/api/config', routes.config);
app.use('/api/config', preAuthTenantMiddleware, optionalJwtAuth, routes.config);
app.use('/api/assistants', routes.assistants);
app.use('/api/files', await routes.files.initialize());
app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute);
app.use('/api/share', routes.share);
app.use('/api/share', preAuthTenantMiddleware, routes.share);
app.use('/api/roles', routes.roles);
app.use('/api/agents', routes.agents);
app.use('/api/banner', routes.banner);
@ -200,8 +222,10 @@ const startServer = async () => {
logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
}
await initializeMCPs();
await initializeOAuthReconnectManager();
await runAsSystem(async () => {
await initializeMCPs();
await initializeOAuthReconnectManager();
});
await checkMigrations();
// Configure stream services (auto-detects Redis from USE_REDIS env var)

View file

@ -0,0 +1,116 @@
/**
* Integration test: verifies that requireJwtAuth chains tenantContextMiddleware
* after successful passport authentication, so ALS tenant context is set for
* all downstream middleware and route handlers.
*
* requireJwtAuth must chain tenantContextMiddleware after passport populates
* req.user (not at global app.use() scope where req.user is undefined).
* If the chaining is removed, these tests fail.
*/
const { getTenantId } = require('@librechat/data-schemas');
// ── Mocks ──────────────────────────────────────────────────────────────
let mockPassportError = null;
jest.mock('passport', () => ({
authenticate: jest.fn(() => {
return (req, _res, done) => {
if (mockPassportError) {
return done(mockPassportError);
}
if (req._mockUser) {
req.user = req._mockUser;
}
done();
};
}),
}));
// Mock @librechat/api — the real tenantContextMiddleware is TS and cannot be
// required directly from CJS tests. This thin wrapper mirrors the real logic
// (read req.user.tenantId, call tenantStorage.run) using the same data-schemas
// primitives. The real implementation is covered by packages/api tenant.spec.ts.
jest.mock('@librechat/api', () => {
const { tenantStorage } = require('@librechat/data-schemas');
return {
isEnabled: jest.fn(() => false),
tenantContextMiddleware: (req, res, next) => {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return next();
}
return tenantStorage.run({ tenantId }, async () => next());
},
};
});
// ── Helpers ─────────────────────────────────────────────────────────────
const requireJwtAuth = require('../requireJwtAuth');
function mockReq(user) {
return { headers: {}, _mockUser: user };
}
function mockRes() {
return { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() };
}
/** Runs requireJwtAuth and returns the tenantId observed inside next(). */
function runAuth(user) {
return new Promise((resolve) => {
const req = mockReq(user);
const res = mockRes();
requireJwtAuth(req, res, () => {
resolve(getTenantId());
});
});
}
// ── Tests ──────────────────────────────────────────────────────────────
describe('requireJwtAuth tenant context chaining', () => {
afterEach(() => {
mockPassportError = null;
});
it('forwards passport errors to next() without entering tenant middleware', async () => {
mockPassportError = new Error('JWT signature invalid');
const req = mockReq(undefined);
const res = mockRes();
const err = await new Promise((resolve) => {
requireJwtAuth(req, res, (e) => resolve(e));
});
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe('JWT signature invalid');
expect(getTenantId()).toBeUndefined();
});
it('sets ALS tenant context after passport auth succeeds', async () => {
const tenantId = await runAuth({ tenantId: 'tenant-abc', role: 'user' });
expect(tenantId).toBe('tenant-abc');
});
it('ALS tenant context is NOT set when user has no tenantId', async () => {
const tenantId = await runAuth({ role: 'user' });
expect(tenantId).toBeUndefined();
});
it('ALS tenant context is NOT set when user is undefined', async () => {
const tenantId = await runAuth(undefined);
expect(tenantId).toBeUndefined();
});
it('concurrent requests get isolated tenant contexts', async () => {
const results = await Promise.all(
['tenant-1', 'tenant-2', 'tenant-3'].map((tid) => runAuth({ tenantId: tid, role: 'user' })),
);
expect(results).toEqual(['tenant-1', 'tenant-2', 'tenant-3']);
});
it('ALS context is not set at top-level scope (outside any request)', () => {
expect(getTenantId()).toBeUndefined();
});
});

View file

@ -0,0 +1,178 @@
const { ViolationTypes } = require('librechat-data-provider');
jest.mock('@librechat/api', () => ({
handleError: jest.fn(),
}));
jest.mock('~/server/controllers/ModelController', () => ({
getModelsConfig: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
getEndpointsConfig: jest.fn(),
}));
jest.mock('~/cache', () => ({
logViolation: jest.fn(),
}));
const { handleError } = require('@librechat/api');
const { getModelsConfig } = require('~/server/controllers/ModelController');
const { getEndpointsConfig } = require('~/server/services/Config');
const { logViolation } = require('~/cache');
const validateModel = require('../validateModel');
describe('validateModel', () => {
let req, res, next;
beforeEach(() => {
jest.clearAllMocks();
req = { body: { model: 'gpt-4o', endpoint: 'openAI' } };
res = {};
next = jest.fn();
getEndpointsConfig.mockResolvedValue({
openAI: { userProvide: false },
});
getModelsConfig.mockResolvedValue({
openAI: ['gpt-4o', 'gpt-4o-mini'],
});
});
describe('format validation', () => {
it('rejects missing model', async () => {
req.body.model = undefined;
await validateModel(req, res, next);
expect(handleError).toHaveBeenCalledWith(res, { text: 'Model not provided' });
expect(next).not.toHaveBeenCalled();
});
it('rejects non-string model', async () => {
req.body.model = 12345;
await validateModel(req, res, next);
expect(handleError).toHaveBeenCalledWith(res, { text: 'Model not provided' });
expect(next).not.toHaveBeenCalled();
});
it('rejects model exceeding 256 chars', async () => {
req.body.model = 'a'.repeat(257);
await validateModel(req, res, next);
expect(handleError).toHaveBeenCalledWith(res, { text: 'Invalid model identifier' });
});
it('rejects model with leading special character', async () => {
req.body.model = '.bad-model';
await validateModel(req, res, next);
expect(handleError).toHaveBeenCalledWith(res, { text: 'Invalid model identifier' });
});
it('rejects model with script injection', async () => {
req.body.model = '<script>alert(1)</script>';
await validateModel(req, res, next);
expect(handleError).toHaveBeenCalledWith(res, { text: 'Invalid model identifier' });
});
it('trims whitespace before validation', async () => {
req.body.model = ' gpt-4o ';
getModelsConfig.mockResolvedValue({ openAI: ['gpt-4o'] });
await validateModel(req, res, next);
expect(next).toHaveBeenCalled();
expect(handleError).not.toHaveBeenCalled();
});
it('rejects model with spaces in the middle', async () => {
req.body.model = 'gpt 4o';
await validateModel(req, res, next);
expect(handleError).toHaveBeenCalledWith(res, { text: 'Invalid model identifier' });
});
it('accepts standard model IDs', async () => {
const validModels = [
'gpt-4o',
'claude-3-5-sonnet-20241022',
'us.amazon.nova-pro-v1:0',
'qwen/qwen3.6-plus-preview:free',
'Meta-Llama-3-8B-Instruct-4bit',
];
for (const model of validModels) {
jest.clearAllMocks();
req.body.model = model;
getEndpointsConfig.mockResolvedValue({ openAI: { userProvide: false } });
getModelsConfig.mockResolvedValue({ openAI: [model] });
next.mockClear();
await validateModel(req, res, next);
expect(next).toHaveBeenCalled();
expect(handleError).not.toHaveBeenCalled();
}
});
});
describe('userProvide early-return', () => {
it('calls next() immediately for userProvide endpoints without checking model list', async () => {
getEndpointsConfig.mockResolvedValue({
openAI: { userProvide: true },
});
req.body.model = 'any-model-from-user-key';
await validateModel(req, res, next);
expect(next).toHaveBeenCalled();
expect(getModelsConfig).not.toHaveBeenCalled();
});
it('does not call getModelsConfig for userProvide endpoints', async () => {
getEndpointsConfig.mockResolvedValue({
CustomEndpoint: { userProvide: true },
});
req.body = { model: 'custom-model', endpoint: 'CustomEndpoint' };
await validateModel(req, res, next);
expect(getModelsConfig).not.toHaveBeenCalled();
expect(next).toHaveBeenCalled();
});
});
describe('system endpoint list validation', () => {
it('rejects a model not in the available list', async () => {
req.body.model = 'not-in-list';
await validateModel(req, res, next);
expect(logViolation).toHaveBeenCalledWith(
req,
res,
ViolationTypes.ILLEGAL_MODEL_REQUEST,
expect.any(Object),
expect.anything(),
);
expect(handleError).toHaveBeenCalledWith(res, { text: 'Illegal model request' });
expect(next).not.toHaveBeenCalled();
});
it('accepts a model in the available list', async () => {
req.body.model = 'gpt-4o';
await validateModel(req, res, next);
expect(next).toHaveBeenCalled();
expect(handleError).not.toHaveBeenCalled();
});
it('rejects when endpoint has no models loaded', async () => {
getModelsConfig.mockResolvedValue({ openAI: undefined });
await validateModel(req, res, next);
expect(handleError).toHaveBeenCalledWith(res, { text: 'Endpoint models not loaded' });
});
it('rejects when modelsConfig is null', async () => {
getModelsConfig.mockResolvedValue(null);
await validateModel(req, res, next);
expect(handleError).toHaveBeenCalledWith(res, { text: 'Models not loaded' });
});
});
});

View file

@ -1,4 +1,5 @@
const { logger } = require('@librechat/data-schemas');
const { isAssistantsEndpoint, ErrorTypes } = require('librechat-data-provider');
const {
isEnabled,
sendEvent,
@ -7,14 +8,11 @@ const {
recordCollectedUsage,
sanitizeMessageForTransmit,
} = require('@librechat/api');
const { isAssistantsEndpoint, ErrorTypes } = require('librechat-data-provider');
const { saveMessage, getConvo, updateBalance, bulkInsertTransactions } = require('~/models');
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
const { getMultiplier, getCacheMultiplier } = require('~/models/tx');
const clearPendingReq = require('~/cache/clearPendingReq');
const { sendError } = require('~/server/middleware/error');
const { abortRun } = require('./abortRun');
const db = require('~/models');
/**
* Spend tokens for all models from collected usage.
@ -44,10 +42,10 @@ async function spendCollectedUsage({
await recordCollectedUsage(
{
spendTokens,
spendStructuredTokens,
pricing: { getMultiplier, getCacheMultiplier },
bulkWriteOps: { insertMany: bulkInsertTransactions, updateBalance },
spendTokens: db.spendTokens,
spendStructuredTokens: db.spendStructuredTokens,
pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
},
{
user: userId,
@ -123,20 +121,24 @@ async function abortMessage(req, res) {
});
} else {
// Fallback: no collected usage, use text-based token counting for primary model only
await spendTokens(
await db.spendTokens(
{ ...responseMessage, context: 'incomplete', user: userId },
{ promptTokens, completionTokens },
);
}
await saveMessage(
req,
await db.saveMessage(
{
userId: req?.user?.id,
isTemporary: req?.body?.isTemporary,
interfaceConfig: req?.config?.interfaceConfig,
},
{ ...responseMessage, user: userId },
{ context: 'api/server/middleware/abortMiddleware.js' },
);
// Get conversation for title
const conversation = await getConvo(userId, conversationId);
const conversation = await db.getConvo(userId, conversationId);
const finalEvent = {
title: conversation && !conversation.title ? null : conversation?.title || 'New Chat',

View file

@ -20,16 +20,6 @@ const mockRecordCollectedUsage = jest
const mockGetMultiplier = jest.fn().mockReturnValue(1);
const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
jest.mock('~/models/spendTokens', () => ({
spendTokens: (...args) => mockSpendTokens(...args),
spendStructuredTokens: (...args) => mockSpendStructuredTokens(...args),
}));
jest.mock('~/models/tx', () => ({
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
@ -73,6 +63,10 @@ jest.mock('~/models', () => ({
getConvo: jest.fn().mockResolvedValue({ title: 'Test Chat' }),
updateBalance: mockUpdateBalance,
bulkInsertTransactions: mockBulkInsertTransactions,
spendTokens: (...args) => mockSpendTokens(...args),
spendStructuredTokens: (...args) => mockSpendStructuredTokens(...args),
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
}));
jest.mock('./abortRun', () => ({

View file

@ -3,8 +3,7 @@ const { logger } = require('@librechat/data-schemas');
const { CacheKeys, RunStatus, isUUID } = require('librechat-data-provider');
const { initializeClient } = require('~/server/services/Endpoints/assistants');
const { checkMessageGaps, recordUsage } = require('~/server/services/Threads');
const { deleteMessages } = require('~/models/Message');
const { getConvo } = require('~/models/Conversation');
const { deleteMessages, getConvo } = require('~/models');
const getLogStores = require('~/cache/getLogStores');
const three_minutes = 1000 * 60 * 3;

View file

@ -10,8 +10,9 @@ const {
} = require('librechat-data-provider');
const { checkPermission } = require('~/server/services/PermissionService');
const { canAccessResource } = require('./canAccessResource');
const { getRoleByName } = require('~/models/Role');
const { getAgent } = require('~/models/Agent');
const db = require('~/models');
const { getRoleByName, getAgent } = db;
/**
* Resolves custom agent ID (e.g., "agent_abc123") to a MongoDB document.

View file

@ -8,7 +8,7 @@ const {
const { MongoMemoryServer } = require('mongodb-memory-server');
const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
const { User, Role, AclEntry } = require('~/db/models');
const { createAgent } = require('~/models/Agent');
const { createAgent } = require('~/models');
describe('canAccessAgentFromBody middleware', () => {
let mongoServer;

View file

@ -1,6 +1,6 @@
const { ResourceType } = require('librechat-data-provider');
const { canAccessResource } = require('./canAccessResource');
const { getAgent } = require('~/models/Agent');
const { getAgent } = require('~/models');
/**
* Agent ID resolver function

View file

@ -3,7 +3,7 @@ const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-
const { MongoMemoryServer } = require('mongodb-memory-server');
const { canAccessAgentResource } = require('./canAccessAgentResource');
const { User, Role, AclEntry } = require('~/db/models');
const { createAgent } = require('~/models/Agent');
const { createAgent } = require('~/models');
describe('canAccessAgentResource middleware', () => {
let mongoServer;
@ -373,7 +373,7 @@ describe('canAccessAgentResource middleware', () => {
jest.clearAllMocks();
// Update the agent
const { updateAgent } = require('~/models/Agent');
const { updateAgent } = require('~/models');
await updateAgent({ id: agentId }, { description: 'Updated description' });
// Test edit access

Some files were not shown because too many files have changed in this diff Show more